Capítulos en cotizaciones

This commit is contained in:
David Arranz 2026-10-19 13:55:16 +02:00
parent c5a40e32a3
commit 64bd7f743d
70 changed files with 3842 additions and 291 deletions

View File

@ -11,7 +11,7 @@ module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"@typescript-eslint/no-require-imports": "none",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",

View File

@ -0,0 +1,162 @@
import { calculateQuoteTotals } from "@/lib/calc";
import {
addChapter,
chapterSubtotal,
ChapterEditorState,
duplicateChapterSelection,
EditorItem,
insertChapterItem,
moveChapterItems,
moveChapters,
removeChapterSelection,
ungroupChapters,
} from "./chapterEditor";
const detail = (
description: string,
chapter_id: string | null,
amount: number | null = 10000,
discount = 0
): EditorItem => ({
id_article: "",
description,
chapter_id,
quantity: { amount: amount === null ? null : 100, scale: 2 },
unit_price: { amount, scale: 2, currency_code: "USD" },
discount: { amount: discount, scale: 2 },
subtotal_price: { amount: 999999, scale: 2, currency_code: "USD" },
total_price: { amount: 999999, scale: 2, currency_code: "USD" },
});
const grouped = (): ChapterEditorState => ({
chapters: [
{ id: "a", description: "Armario" },
{ id: "b", description: "Interior" },
{ id: "c", description: "Vacío" },
],
items: [
detail("Panel", "a", 10000, 1000),
detail("Texto", "a", null),
detail("Balda", "b", 5000),
],
});
const assertMembership = (state: ChapterEditorState) => {
expect(
state.items.every((item) =>
state.chapters.length
? state.chapters.some((chapter) => chapter.id === item.chapter_id)
: item.chapter_id === null
)
).toBe(true);
};
it("groups every existing line on first chapter creation and rejects whitespace titles", () => {
const state = { chapters: [], items: [detail("Panel", null), detail("Texto", null, null)] };
expect(
addChapter(state, "Armario", null, () => "first").items.map((item) => item.chapter_id)
).toEqual(["first", "first"]);
expect(() => addChapter(state, " \n ", null)).toThrow();
expect(state.chapters).toEqual([]);
expect(addChapter({ chapters: [], items: [] }, "Vacío", null, () => "first").items).toEqual([]);
});
it("inserts a new chapter after the active one and assigns new details to the destination", () => {
const state = addChapter(grouped(), "Otro", "a", () => "new");
expect(state.chapters.map((chapter) => chapter.id)).toEqual(["a", "new", "b", "c"]);
expect(
insertChapterItem(state, detail("Nuevo", null), "new").items.find(
(item) => item.description === "Nuevo"
)?.chapter_id
).toBe("new");
expect(
insertChapterItem(state, detail("Final", null), null).items.find(
(item) => item.description === "Final"
)?.chapter_id
).toBe("c");
// Inserting below the final line of chapter A must stay in A.
expect(
insertChapterItem(grouped(), detail("Debajo", null), "a", 1, true).items.map(
(item) => item.chapter_id
)
).toEqual(["a", "a", "a", "b"]);
});
it("moves complete chapter units and keeps their interior order", () => {
const moved = moveChapters(grouped(), ["a", "c"], "b", true);
expect(moved.chapters.map((chapter) => chapter.id)).toEqual(["b", "a", "c"]);
expect(moved.items.map((item) => item.description)).toEqual(["Balda", "Panel", "Texto"]);
assertMembership(moved);
});
it("moves multiple details to an empty chapter and supports before/after insertion", () => {
const state = grouped();
const moved = moveChapterItems(state, [0, 1], "c");
expect(moved.items.map((item) => [item.description, item.chapter_id])).toEqual([
["Balda", "b"],
["Panel", "c"],
["Texto", "c"],
]);
expect(chapterSubtotal(moved.items, "a", "USD").toPrimitive()).toBe(0);
expect(moveChapterItems(state, [2], "a", 0, false).items.map((item) => item.description)).toEqual(
["Balda", "Panel", "Texto"]
);
expect(moveChapterItems(state, [2], "a", 0, true).items.map((item) => item.description)).toEqual([
"Panel",
"Balda",
"Texto",
]);
expect(moveChapterItems(state, [0], "missing")).toBe(state);
assertMembership(moved);
});
it("duplicates selected chapters once, even when their children are selected, using current values", () => {
const state = grouped();
state.items[0].description = "Editado sin desenfocar";
const copy = duplicateChapterSelection(state, ["a"], [0, 2], () => "copy");
expect(copy.chapters.map((chapter) => chapter.id)).toEqual(["a", "copy", "b", "c"]);
expect(
copy.items.filter((item) => item.chapter_id === "copy").map((item) => item.description)
).toEqual(["Editado sin desenfocar", "Texto"]);
expect(copy.items.filter((item) => item.chapter_id === "b")).toHaveLength(2);
assertMembership(copy);
});
it("deletes or transfers selected content without orphaning or double-counting children", () => {
const removed = removeChapterSelection(grouped(), ["a"], [0]);
expect(removed.items.map((item) => item.description)).toEqual(["Balda"]);
const transferred = removeChapterSelection(grouped(), ["a"], [0], "b");
expect(transferred.items.map((item) => item.description)).toEqual(["Balda", "Panel", "Texto"]);
expect(() => removeChapterSelection(grouped(), ["a"], [], "a")).toThrow();
assertMembership(transferred);
assertMembership(removed);
});
it("ungroups the final chapter without dropping any details and preserves visual order", () => {
const state = moveChapters(grouped(), ["b"], "a", false);
const free = ungroupChapters(state);
expect(free.chapters).toEqual([]);
expect(free.items.map((item) => item.description)).toEqual(["Balda", "Panel", "Texto"]);
assertMembership(free);
});
it("derives subtotals from current fields, excludes text and keeps currency and general totals", () => {
const state = grouped();
expect(chapterSubtotal(state.items, "a", "USD").toObject()).toEqual({
amount: 9000,
scale: 2,
currency_code: "USD",
});
const total = (structure: ChapterEditorState) =>
calculateQuoteTotals(
{
...structure,
currency_code: "USD",
discount: { amount: 1000, scale: 2 },
tax: { amount: 2100, scale: 2 },
},
true
).total_price.toObject();
expect(total(state)).toEqual({ amount: 15246, scale: 2, currency_code: "USD" });
expect(total(moveChapters(state, ["a"], "b", true))).toEqual(total(state));
expect(total(moveChapterItems(state, [0], "c"))).toEqual(total(state));
expect(total(ungroupChapters(state))).toEqual(total(state));
});

View File

@ -0,0 +1,203 @@
import {
IGetQuote_QuoteItem_Response_DTO,
IGetQuote_Response_DTO,
MoneyValue,
UniqueID,
} from "@shared/contexts";
import { calculateQuoteItemTotals } from "@/lib/calc";
export type EditorChapter = { id: string; description: string };
export type EditorItem = IGetQuote_QuoteItem_Response_DTO;
export type ChapterEditorState = { chapters: EditorChapter[]; items: EditorItem[] };
const newId = () => UniqueID.generateNewID().object.toString();
export function orderChapterItems(state: ChapterEditorState): ChapterEditorState {
if (
state.chapters.length &&
state.items.some((item) => !state.chapters.some((chapter) => chapter.id === item.chapter_id))
) {
throw new Error("A detail must belong to an existing chapter");
}
return {
chapters: state.chapters,
items: state.chapters.length
? state.chapters.flatMap((chapter) =>
state.items.filter((item) => item.chapter_id === chapter.id)
)
: state.items.map((item) => ({ ...item, chapter_id: null })),
};
}
export function quoteUpdatePayload(data: IGetQuote_Response_DTO) {
return {
...data,
chapters: (data.chapters ?? []).map(({ id, description }) => ({ id, description })),
show_chapter_line_amounts: data.show_chapter_line_amounts ?? true,
items: data.items.map((item) => {
const writable = { ...item };
delete writable.localized_description;
return writable;
}),
};
}
export function addChapter(
state: ChapterEditorState,
description: string,
activeId: string | null,
generateId = newId
): ChapterEditorState {
if (!description.trim()) throw new Error("Chapter description is required");
const chapter = { id: generateId(), description };
const activeIndex = state.chapters.findIndex((entry) => entry.id === activeId);
const chapters = [...state.chapters];
chapters.splice(activeIndex < 0 ? chapters.length : activeIndex + 1, 0, chapter);
return orderChapterItems({
chapters,
items: state.chapters.length
? state.items
: state.items.map((item) => ({ ...item, chapter_id: chapter.id })),
});
}
export function insertChapterItem(
state: ChapterEditorState,
item: EditorItem,
chapterId: string | null,
referenceIndex?: number,
after = false
): ChapterEditorState {
const target =
state.chapters.find((chapter) => chapter.id === chapterId)?.id ??
state.chapters[state.chapters.length - 1]?.id ??
null;
const items = [...state.items];
items.splice(referenceIndex === undefined ? items.length : referenceIndex + Number(after), 0, {
...item,
chapter_id: target,
});
return orderChapterItems({ ...state, items });
}
export function duplicateChapterSelection(
state: ChapterEditorState,
chapterIds: string[],
indexes: number[],
generateId = newId
): ChapterEditorState {
const selectedChapters = new Set(chapterIds);
const selectedItems = new Set(indexes);
const copies = state.chapters
.filter((chapter) => selectedChapters.has(chapter.id))
.map((chapter) => ({ ...chapter, id: generateId() }));
const chapters = [...state.chapters];
const lastChapter = state.chapters.reduce(
(last, chapter, index) => (selectedChapters.has(chapter.id) ? index : last),
-1
);
chapters.splice(lastChapter + 1, 0, ...copies);
const items = [...state.items];
state.chapters.forEach((chapter) => {
if (selectedChapters.has(chapter.id)) return;
const selected = state.items.filter(
(item, index) => item.chapter_id === chapter.id && selectedItems.has(index)
);
if (!selected.length) return;
const last = items.indexOf(selected[selected.length - 1]);
items.splice(last + 1, 0, ...selected.map((item) => ({ ...item })));
});
state.chapters
.filter((chapter) => selectedChapters.has(chapter.id))
.forEach((chapter, index) => {
items.push(
...state.items
.filter((item) => item.chapter_id === chapter.id)
.map((item) => ({ ...item, chapter_id: copies[index].id }))
);
});
return orderChapterItems({ chapters, items });
}
export function ungroupChapters(state: ChapterEditorState): ChapterEditorState {
return {
chapters: [],
items: orderChapterItems(state).items.map((item) => ({ ...item, chapter_id: null })),
};
}
export function removeChapterSelection(
state: ChapterEditorState,
chapterIds: string[],
indexes: number[],
targetId?: string
): ChapterEditorState {
const selectedChapters = new Set(chapterIds);
const selectedItems = new Set(indexes);
const chapters = state.chapters.filter((chapter) => !selectedChapters.has(chapter.id));
if (targetId && !chapters.some((chapter) => chapter.id === targetId))
throw new Error("Invalid destination chapter");
const affected = (item: EditorItem, index: number) =>
selectedItems.has(index) || selectedChapters.has(item.chapter_id ?? "");
const items = state.items.filter((item, index) => !affected(item, index));
if (targetId)
items.push(...state.items.filter(affected).map((item) => ({ ...item, chapter_id: targetId })));
return orderChapterItems({ chapters, items });
}
export function moveChapters(
state: ChapterEditorState,
chapterIds: string[],
targetId: string,
after: boolean
): ChapterEditorState {
const selected = new Set(chapterIds);
if (selected.has(targetId)) return state;
const chapters = state.chapters.filter((chapter) => !selected.has(chapter.id));
const target = chapters.findIndex((chapter) => chapter.id === targetId);
if (target < 0) return state;
chapters.splice(
target + Number(after),
0,
...state.chapters.filter((chapter) => selected.has(chapter.id))
);
return orderChapterItems({ chapters, items: state.items });
}
export function moveChapterItems(
state: ChapterEditorState,
indexes: number[],
chapterId: string,
referenceIndex?: number,
after = false
): ChapterEditorState {
if (!state.chapters.some((chapter) => chapter.id === chapterId)) return state;
const selected = new Set(indexes);
if (referenceIndex !== undefined && selected.has(referenceIndex)) return state;
const moving = state.items
.filter((_item, index) => selected.has(index))
.map((item) => ({ ...item, chapter_id: chapterId }));
const items = state.items.filter((_item, index) => !selected.has(index));
const target =
referenceIndex === undefined ? items.length : items.indexOf(state.items[referenceIndex]);
items.splice(target < 0 ? items.length : target + Number(after), 0, ...moving);
return orderChapterItems({ chapters: state.chapters, items });
}
export function chapterSubtotal(
items: EditorItem[],
chapterId: string,
currencyCode: string
): MoneyValue {
return items.reduce(
(sum, item) => {
if (
item.chapter_id !== chapterId ||
item.quantity?.amount == null ||
item.unit_price?.amount == null
)
return sum;
return sum.add(calculateQuoteItemTotals(item).total_price);
},
MoneyValue.create({ amount: 0, scale: 2, currencyCode }).object
);
}

View File

@ -0,0 +1,80 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@/ui";
import { useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export function QuoteChapterCreateDialog({
open,
onOpenChange,
onCreate,
}: {
open: boolean;
onOpenChange: (value: boolean) => void;
onCreate: (description: string) => void;
}) {
const { t } = useTranslation();
const id = useId();
const created = useRef(false);
const [description, setDescription] = useState("");
const close = () => {
setDescription("");
onOpenChange(false);
};
const create = () => {
if (!description.trim()) return;
created.current = true;
onCreate(description);
close();
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) close();
}}
>
<DialogContent
onCloseAutoFocus={(event) => {
if (created.current) {
event.preventDefault();
created.current = false;
}
}}
>
<DialogHeader>
<DialogTitle>{t("quotes.chapters.add")}</DialogTitle>
<DialogDescription>{t("quotes.chapters.create_description")}</DialogDescription>
</DialogHeader>
<Label htmlFor={id}>{t("quotes.chapters.description")}</Label>
<Input
id={id}
value={description}
onChange={(event) => setDescription(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
create();
}
}}
/>
<DialogFooter>
<Button type='button' variant='secondary' onClick={close}>
{t("common.cancel")}
</Button>
<Button type='button' disabled={!description.trim()} onClick={create}>
{t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,697 @@
import {
Button,
Card,
CardContent,
CardHeader,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
} from "@/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table";
import {
DndContext,
closestCenter,
pointerWithin,
DragEndEvent,
KeyboardCoordinateGetter,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ColumnDef,
RowSelectionState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDownIcon, ChevronRightIcon, CopyIcon, GripVerticalIcon, Trash2Icon } from "lucide-react";
import { Fragment, ReactNode, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
chapterSubtotal,
duplicateChapterSelection,
EditorChapter,
EditorItem,
insertChapterItem,
moveChapterItems,
moveChapters,
removeChapterSelection,
ungroupChapters,
} from "../chapterEditor";
import { ChapterEditorController } from "../hooks/useChapterEditor";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
import { RowIdData } from "./QuoteItemsSortableDataTable";
type Selection = { chapters: string[]; indexes: number[] };
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
// Keyboard and pointer sensors share the same valid destination types.
const keyboardCoordinates: KeyboardCoordinateGetter = (
event,
{ active, currentCoordinates, context }
) => {
if (!["ArrowUp", "ArrowDown"].includes(event.code)) return;
event.preventDefault();
const containers = context.droppableContainers
.getEnabled()
.filter((entry) => isChapter(entry.id) === isChapter(active))
.sort(
(a, b) =>
(context.droppableRects.get(a.id)?.top ?? 0) - (context.droppableRects.get(b.id)?.top ?? 0)
);
const current = context.over?.id ?? active;
const index = containers.findIndex((entry) => entry.id === current);
const next = containers[index + (event.code === "ArrowDown" ? 1 : -1)];
const origin = context.droppableRects.get(current);
const target = next && context.droppableRects.get(next.id);
if (origin && target)
return {
x: currentCoordinates.x + target.left - origin.left,
y: currentCoordinates.y + target.top + target.height / 2 - origin.top - origin.height / 2,
};
};
function EmptyChapterTarget({ id, columns, hidden }: { id: string; columns: number; hidden: boolean }) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ id: `empty:${id}`, disabled: hidden });
return (
<TableRow ref={setNodeRef} hidden={hidden} className={isOver ? "bg-accent" : ""}>
<TableCell colSpan={columns} className='h-16 text-center text-muted-foreground'>
{t("quotes.chapters.empty")}
</TableCell>
</TableRow>
);
}
function ChapterGroup({
chapter,
index,
columns,
children,
selected,
onSelect,
onActivate,
onDuplicate,
onRemove,
mixed,
subtotal,
}: {
chapter: EditorChapter;
index: number;
columns: number;
children: (collapsed: boolean) => ReactNode;
selected: boolean;
onSelect: (selected: boolean) => void;
onActivate: () => void;
onDuplicate: () => void;
onRemove: () => void;
mixed: boolean;
subtotal: string;
}) {
const { t } = useTranslation();
const { control } = useFormContext();
const [collapsed, setCollapsed] = useState(false);
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `chapter:${chapter.id}`, disabled: mixed });
return (
<TableBody
ref={setNodeRef}
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<TableRow className='bg-accent hover:bg-accent' data-chapter-id={chapter.id}>
<TableCell className='px-2'>
<Checkbox
checked={selected}
onCheckedChange={(value) => onSelect(Boolean(value))}
aria-label={t("quotes.chapters.select", { number: index + 1 })}
/>
</TableCell>
<TableCell className='px-2'>
<Button
type='button'
variant='ghost'
size='icon'
disabled={mixed}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='touch-none cursor-grab'
onFocus={onActivate}
aria-label={t("quotes.chapters.move", { number: index + 1 })}
>
<GripVerticalIcon className='h-4 w-4' />
</Button>
</TableCell>
<TableCell colSpan={columns - 2} className='px-2'>
<div className='flex items-start gap-2'>
<Button
type='button'
variant='ghost'
size='icon'
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
})}
onClick={() => {
setCollapsed((previous) => !previous);
onActivate();
}}
>
{collapsed ? (
<ChevronRightIcon className='h-4 w-4' />
) : (
<ChevronDownIcon className='h-4 w-4' />
)}
</Button>
<span className='py-2 font-semibold'>{index + 1}.</span>
<Controller
key={`${chapter.id}:${index}`}
control={control}
name={`chapters.${index}.description`}
rules={{
validate: (value: string) =>
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
}}
render={({ field, fieldState }) => (
<div className='flex-1'>
<Textarea
{...field}
value={field.value ?? ""}
rows={1}
onFocus={onActivate}
aria-label={t("quotes.chapters.title", { number: index + 1 })}
aria-invalid={Boolean(fieldState.error)}
className='font-semibold'
/>
{fieldState.error && (
<p role='alert' className='text-sm text-destructive'>
{fieldState.error.message}
</p>
)}
</div>
)}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={onDuplicate}
aria-label={t("quotes.chapters.duplicate", { number: index + 1 })}
>
<CopyIcon className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
onClick={onRemove}
aria-label={t("quotes.chapters.remove", { number: index + 1 })}
>
<Trash2Icon className='h-4 w-4' />
</Button>
</div>
</TableCell>
</TableRow>
{children(collapsed)}
<TableRow className='bg-accent/70 font-semibold' data-chapter-footer={chapter.id}>
<TableCell colSpan={columns - 1}>
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
</TableCell>
<TableCell className='text-right whitespace-nowrap'>{subtotal}</TableCell>
</TableRow>
</TableBody>
);
}
export function QuoteChaptersDataTable({
controller,
columns,
data,
defaultItem,
currencyCode,
showLineDiscountColumn,
hasLineDiscounts,
onShowLineDiscountColumnChange,
onAddChapter,
pickCatalogArticle,
pickBlock,
}: {
controller: ChapterEditorController;
columns: ColumnDef<RowIdData, unknown>[];
data: RowIdData[];
defaultItem: EditorItem;
currencyCode: string;
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
onAddChapter: () => void;
pickCatalogArticle: () => void;
pickBlock: () => void;
}) {
const { t } = useTranslation();
const { watch, setValue } = useFormContext();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [chapterSelection, setChapterSelection] = useState<string[]>([]);
const [activeRow, setActiveRow] = useState<string | null>(null);
const [deleting, setDeleting] = useState<Selection | null>(null);
const [ungrouping, setUngrouping] = useState(false);
const [targetId, setTargetId] = useState("");
const { chapters, items } = controller;
const selectedIndexes = data.flatMap((row, index) => (rowSelection[row.id] ? [index] : []));
const mixed =
chapterSelection.length > 0 &&
selectedIndexes.some((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""));
const clear = () => {
setRowSelection({});
setChapterSelection([]);
setActiveRow(null);
};
const apply = (state: Parameters<typeof controller.apply>[0]) => {
controller.apply(state);
clear();
};
const duplicate = (selection: Selection) =>
apply(duplicateChapterSelection(controller.read(), selection.chapters, selection.indexes));
const remove = (selection: Selection) => {
const state = controller.read();
const affected = state.items.some(
(item, index) =>
selection.indexes.includes(index) || selection.chapters.includes(item.chapter_id ?? "")
);
if (!affected) {
const next = removeChapterSelection(state, selection.chapters, selection.indexes);
if (selection.chapters.includes(controller.activeId ?? "")) {
const index = state.chapters.findIndex((chapter) => chapter.id === controller.activeId);
controller.setActiveId(
next.chapters[Math.max(0, index - 1)]?.id ?? next.chapters[0]?.id ?? null
);
}
apply(next);
return;
}
setTargetId("");
setDeleting(selection);
};
const finishRemove = (mode: "delete" | "transfer" | "ungroup") => {
if (!deleting) return;
const state = controller.read();
const deletedIndex = state.chapters.findIndex((chapter) => chapter.id === controller.activeId);
const next =
mode === "ungroup"
? ungroupChapters(state)
: removeChapterSelection(
state,
deleting.chapters,
deleting.indexes,
mode === "transfer" ? targetId : undefined
);
if (!next.chapters.some((chapter) => chapter.id === controller.activeId)) {
controller.setActiveId(
next.chapters[Math.max(0, deletedIndex - 1)]?.id ?? next.chapters[0]?.id ?? null
);
}
apply(next);
setDeleting(null);
};
const selection = { chapters: chapterSelection, indexes: selectedIndexes };
const selectedCount =
chapterSelection.length +
selectedIndexes.filter((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""))
.length;
const insert = (index: number, after = false) => {
const state = controller.read();
apply(
insertChapterItem(
state,
defaultItem,
state.items[index]?.chapter_id ?? controller.destination(),
index,
after
)
);
};
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
state: { rowSelection, columnVisibility: { discount: showLineDiscountColumn } },
onRowSelectionChange: setRowSelection,
enableSorting: false,
meta: {
insertItem: (index) => insert(index),
insertItemBelow: (index) => insert(index, true),
appendItem: () =>
apply(insertChapterItem(controller.read(), defaultItem, controller.destination())),
pickCatalogArticle,
pickBlock,
duplicateItems: (index) =>
duplicate(index === undefined ? selection : { chapters: [], indexes: [index] }),
deleteItems: (index) =>
remove(
index === undefined
? selection
: { chapters: [], indexes: Array.isArray(index) ? index : [index] }
),
updateItem: (index, rowData, fieldName, value) => {
const state = controller.read();
apply({
...state,
items: state.items.map((item, itemIndex) =>
itemIndex === index ? { ...rowData, ...item, [fieldName]: value } : item
),
});
},
},
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates })
);
const dragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id || mixed) return;
const state = controller.read();
if (isChapter(active.id)) {
if (!isChapter(over.id)) return;
const from = String(active.id).slice(8);
const to = String(over.id).slice(8);
const moving = chapterSelection.includes(from) ? chapterSelection : [from];
apply(
moveChapters(
state,
moving,
to,
state.chapters.findIndex((chapter) => chapter.id === from) <
state.chapters.findIndex((chapter) => chapter.id === to)
)
);
} else {
const from = data.findIndex((item) => item.id === active.id);
if (from < 0) return;
const moving = selectedIndexes.includes(from) ? selectedIndexes : [from];
const to = data.findIndex((item) => item.id === over.id);
const chapterId = String(over.id).startsWith("empty:")
? String(over.id).slice(6)
: state.items[to]?.chapter_id;
if (!chapterId) return;
const center = active.rect.current.translated;
const after =
to >= 0 &&
(center
? center.top + center.height / 2 >= over.rect.top + over.rect.height / 2
: from < to);
apply(moveChapterItems(state, moving, chapterId, to < 0 ? undefined : to, after));
controller.setActiveId(chapterId);
}
};
const destinations = chapters.filter((chapter) => !deleting?.chapters.includes(chapter.id));
const affectedCount = deleting
? items.filter(
(item, index) =>
deleting.indexes.includes(index) || deleting.chapters.includes(item.chapter_id ?? "")
).length
: 0;
const countColumns = table.getVisibleLeafColumns().length;
return (
<>
<DndContext
sensors={sensors}
onDragEnd={dragEnd}
onDragStart={({ active }) => {
if (
isChapter(active.id)
? !chapterSelection.includes(String(active.id).slice(8))
: !rowSelection[active.id]
)
clear();
}}
collisionDetection={(args) => {
const valid = {
...args,
droppableContainers: args.droppableContainers.filter(
(entry) => isChapter(entry.id) === isChapter(args.active.id)
),
};
return args.pointerCoordinates ? pointerWithin(valid) : closestCenter(valid);
}}
accessibility={{
screenReaderInstructions: { draggable: t("quotes.chapters.drag_instructions") },
announcements: {
onDragStart: () => t("quotes.chapters.drag_start"),
onDragOver: () => t("quotes.chapters.drag_over"),
onDragEnd: () => t("quotes.chapters.drag_end"),
onDragCancel: () => t("quotes.chapters.drag_cancel"),
},
}}
>
<Card>
<CardHeader className='sticky z-10 top-16 bg-card/90'>
<QuoteItemsSortableDataTableToolbar
table={table}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
selectionActions={{
count: selectedCount,
duplicate: () => duplicate(selection),
remove: () => remove(selection),
clear,
}}
extraControls={
<>
<Button type='button' variant='link' onClick={onAddChapter}>
{t("quotes.chapters.add")}
</Button>
<Button type='button' variant='link' onClick={() => setUngrouping(true)}>
{t("quotes.chapters.ungroup")}
</Button>
<div className='flex items-center gap-2'>
<Label htmlFor='chapter-line-amounts'>
{t("quotes.chapters.show_amounts")}
</Label>
<Switch
id='chapter-line-amounts'
checked={watch("show_chapter_line_amounts") ?? true}
onCheckedChange={(value) =>
setValue("show_chapter_line_amounts", value, { shouldDirty: true })
}
/>
</div>
</>
}
/>
{mixed && (
<p role='status' className='text-sm'>
{t("quotes.chapters.mixed_selection")}
</p>
)}
</CardHeader>
<CardContent>
<Table className='table-fixed'>
<TableHeader>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className='px-2'
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<SortableContext
items={chapters.map((chapter) => `chapter:${chapter.id}`)}
strategy={verticalListSortingStrategy}
>
{chapters.map((chapter, chapterIndex) => {
const rows = table
.getRowModel()
.rows.filter((row) => items[row.index]?.chapter_id === chapter.id);
return (
<ChapterGroup
key={chapter.id}
chapter={chapter}
index={chapterIndex}
columns={countColumns}
mixed={mixed}
selected={chapterSelection.includes(chapter.id)}
onSelect={(value) =>
setChapterSelection((previous) =>
value
? [...previous, chapter.id]
: previous.filter((id) => id !== chapter.id)
)
}
onActivate={() => controller.setActiveId(chapter.id)}
onDuplicate={() => duplicate({ chapters: [chapter.id], indexes: [] })}
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
subtotal={chapterSubtotal(items, chapter.id, currencyCode).toFormat()}
>
{(collapsed) => (
<SortableContext
items={collapsed ? [] : rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<Fragment key={row.id}>
<QuoteItemsSortableTableRow
id={row.id}
hidden={collapsed}
disabled={collapsed || mixed || chapterSelection.length > 0}
isActive={activeRow === row.id}
onActivate={(event) => {
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
return;
setActiveRow(row.id);
controller.setActiveId(chapter.id);
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='px-2 py-2 align-top'>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{activeRow === row.id && (
<TableRow hidden={collapsed}>
<TableCell colSpan={countColumns}>
<QuoteItemsInlineRowActions
confirmDelete={false}
row={row}
table={table}
onDelete={() => setActiveRow(null)}
/>
</TableCell>
</TableRow>
)}
</Fragment>
))}
{!rows.length && (
<EmptyChapterTarget id={chapter.id} columns={countColumns} hidden={collapsed} />
)}
</SortableContext>
)}
</ChapterGroup>
);
})}
</SortableContext>
</Table>
</CardContent>
</Card>
</DndContext>
<Dialog
open={Boolean(deleting)}
onOpenChange={(open) => {
if (!open) setDeleting(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("quotes.chapters.delete_title")}</DialogTitle>
<DialogDescription>
{t("quotes.chapters.delete_description", {
chapters: deleting?.chapters.length ?? 0,
details: affectedCount,
})}
</DialogDescription>
</DialogHeader>
{Boolean(deleting?.chapters.length) && destinations.length > 0 && (
<>
<Label htmlFor='chapter-transfer'>{t("quotes.chapters.destination")}</Label>
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger id='chapter-transfer'>
<SelectValue placeholder={t("quotes.chapters.destination")} />
</SelectTrigger>
<SelectContent>
{destinations.map((chapter) => (
<SelectItem key={chapter.id} value={chapter.id}>
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}. {chapter.description}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<DialogFooter className='flex-wrap gap-2'>
<Button type='button' variant='secondary' onClick={() => setDeleting(null)}>
{t("common.cancel")}
</Button>
{Boolean(deleting?.chapters.length) &&
(destinations.length ? (
<Button type='button' disabled={!targetId} onClick={() => finishRemove("transfer")}>
{t("quotes.chapters.transfer")}
</Button>
) : (
<Button type='button' onClick={() => finishRemove("ungroup")}>
{t("quotes.chapters.ungroup_keep")}
</Button>
))}
<Button type='button' variant='destructive' onClick={() => finishRemove("delete")}>
{t("quotes.chapters.delete_content")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={ungrouping} onOpenChange={setUngrouping}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("quotes.chapters.ungroup")}</DialogTitle>
<DialogDescription>{t("quotes.chapters.ungroup_description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type='button' variant='secondary' onClick={() => setUngrouping(false)}>
{t("common.cancel")}
</Button>
<Button
type='button'
onClick={() => {
apply(ungroupChapters(controller.read()));
controller.setActiveId(null);
setUngrouping(false);
}}
>
{t("quotes.chapters.ungroup_keep")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -14,10 +14,11 @@ import { Row, RowData, Table } from "@tanstack/react-table";
import { t } from "i18next";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table, onDelete }: {
export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table, onDelete, confirmDelete = true }: {
row: Row<TData>;
table: Table<TData>;
onDelete: () => void;
confirmDelete?: boolean;
}) => {
const handleDelete = () => {
table.options.meta?.deleteItems(row.index);
@ -34,7 +35,9 @@ export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table,
type='button'
variant='outline'
size='sm'
onClick={() => table.options.meta?.insertItem(row.index + 1)}
onClick={() => table.options.meta?.insertItemBelow
? table.options.meta.insertItemBelow(row.index)
: table.options.meta?.insertItem(row.index + 1)}
>
<ChevronDownIcon className='w-4 h-4 mr-2' />
{t("common.insert_row_below")}
@ -43,7 +46,7 @@ export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table,
<CopyIcon className='w-4 h-4 mr-2' />
{t("common.duplicate_row")}
</Button>
<AlertDialog>
{confirmDelete ? <AlertDialog>
<AlertDialogTrigger asChild>
<Button type='button' variant='destructive' size='sm'>
<Trash2Icon className='w-4 h-4 mr-2' />
@ -66,7 +69,9 @@ export const QuoteItemsInlineRowActions = <TData extends RowData>({ row, table,
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialog> : <Button type="button" variant="destructive" size="sm" onClick={handleDelete}>
<Trash2Icon className="w-4 h-4 mr-2" />{t("common.remove_row")}
</Button>}
</div>
);
};

View File

@ -30,7 +30,7 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Fragment, useMemo, useState } from "react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
@ -44,6 +44,7 @@ export type RowIdData = { [x: string]: any };
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
insertItemBelow?: (rowIndex: number) => void;
insertItem: (rowIndex: number, data?: unknown) => void;
appendItem: (data?: unknown) => void;
pickCatalogArticle?: () => void;
@ -67,6 +68,7 @@ export type QuoteItemsSortableDataTableProps<
TData extends RowData & RowIdData,
TValue = unknown
> = {
extraControls?: ReactNode;
columns: ColumnDef<TData, TValue>[];
data: TData[];
defaultValues: Readonly<{ [x: string]: any }> | undefined;
@ -112,6 +114,7 @@ const dropAnimationConfig: DropAnimation = {
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>({
columns,
extraControls,
data,
defaultValues,
initialState,
@ -303,6 +306,7 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
<CardTitle>
<QuoteItemsSortableDataTableToolbar
table={table}
extraControls={extraControls}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}

View File

@ -1,4 +1,3 @@
import { FormLabel } from "@/components";
import {
Button,
Dialog,
@ -9,6 +8,7 @@ import {
DialogTitle,
Separator,
Switch,
Label,
Tooltip,
TooltipContent,
TooltipTrigger,
@ -16,7 +16,7 @@ import {
import { Table } from "@tanstack/react-table";
import { t } from "i18next";
import { CopyPlusIcon, ScanIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { ReactNode, useState } from "react";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
@ -26,13 +26,17 @@ export const QuoteItemsSortableDataTableToolbar = ({
showLineDiscountColumn,
hasLineDiscounts,
onShowLineDiscountColumnChange,
extraControls,
selectionActions,
}: {
table: Table<any>;
extraControls?: ReactNode;
selectionActions?: { count: number; duplicate: () => void; remove: () => void; clear: () => void };
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
}) => {
const selectedRowsCount = table.getSelectedRowModel().rows.length;
const selectedRowsCount = selectionActions?.count ?? table.getSelectedRowModel().rows.length;
const [confirmationOpen, setConfirmationOpen] = useState(false);
const handleCheckedChange = (checked: boolean) => {
@ -48,7 +52,7 @@ export const QuoteItemsSortableDataTableToolbar = ({
<>
<div className='flex items-center gap-2 ml-auto'>
<div className='grid gap-0.5'>
<FormLabel label={t("quotes.discount_column.label")} />
<Label htmlFor="show-line-discount-column">{t("quotes.discount_column.label")}</Label>
</div>
<Switch
id='show-line-discount-column'
@ -85,15 +89,15 @@ export const QuoteItemsSortableDataTableToolbar = ({
if (selectedRowsCount) {
return (
<nav className='flex items-center h-12 p-1 rounded-md text-muted-foreground bg-muted '>
<div className='flex items-center gap-2'>
<nav className='flex items-center min-h-12 flex-wrap gap-2 p-1 rounded-md text-muted-foreground bg-muted '>
<div className='flex flex-wrap items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.options.meta?.duplicateItems()}
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.duplicate() : table.options.meta?.duplicateItems()}
>
<CopyPlusIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
@ -108,8 +112,8 @@ export const QuoteItemsSortableDataTableToolbar = ({
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.options.meta?.deleteItems()}
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.remove() : table.options.meta?.deleteItems()}
>
<Trash2Icon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
@ -123,8 +127,8 @@ export const QuoteItemsSortableDataTableToolbar = ({
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.resetRowSelection()}
disabled={!selectedRowsCount}
onClick={() => selectionActions ? selectionActions.clear() : table.resetRowSelection()}
>
<ScanIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.reset_selected_rows")}</span>
@ -136,6 +140,7 @@ export const QuoteItemsSortableDataTableToolbar = ({
<p className='text-sm font-normal'>
{t("common.rows_selected", { count: selectedRowsCount })}
</p>
{extraControls}
{discountColumnControl}
</div>
</nav>
@ -143,7 +148,7 @@ export const QuoteItemsSortableDataTableToolbar = ({
}
return (
<nav className='flex items-center h-12 p-1 rounded-md bg-accent/75 text-muted-foreground'>
<nav className='flex items-center min-h-12 flex-wrap gap-2 p-1 rounded-md bg-accent/75 text-muted-foreground'>
<div className='flex space-x-2'>
<Tooltip>
<TooltipTrigger asChild>
@ -178,6 +183,7 @@ export const QuoteItemsSortableDataTableToolbar = ({
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
</Tooltip>
</div>
{extraControls}
{discountColumnControl}
</nav>
);

View File

@ -29,7 +29,7 @@ export const QuoteItemsSortableDragHandleCell = ({
variant='link'
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground touch-none",
className
)}
{...attributes}

View File

@ -3,7 +3,7 @@ import { TableRow } from "@/ui/table";
import { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CSSProperties, PropsWithChildren, createContext, useMemo } from "react";
import { CSSProperties, PropsWithChildren, SyntheticEvent, createContext, useMemo } from "react";
import { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable";
export interface QuoteItemsSortableTableRowContextValue {
@ -15,7 +15,9 @@ export interface QuoteItemsSortableTableRowContextValue {
export interface QuoteItemsSortableTableRowProps extends QuoteItemsSortableProps {
isActive?: boolean;
onActivate?: () => void;
hidden?: boolean;
onActivate?: (event: SyntheticEvent<HTMLTableRowElement>) => void;
disabled?: boolean;
}
export const QuoteItemsSortableTableRowContext = createContext<QuoteItemsSortableTableRowContextValue>({
attributes: {
@ -43,7 +45,9 @@ export function QuoteItemsSortableTableRow({
id,
children,
isActive = false,
hidden = false,
onActivate,
disabled = false,
}: PropsWithChildren<QuoteItemsSortableTableRowProps>) {
const {
attributes,
@ -56,6 +60,7 @@ export function QuoteItemsSortableTableRow({
} = useSortable({
animateLayoutChanges,
id,
disabled,
});
const style: CSSProperties = {
@ -78,6 +83,7 @@ export function QuoteItemsSortableTableRow({
<TableRow
key={id}
id={String(id)}
hidden={hidden}
className={cn(
isDragging ? "opacity-60" : "opacity-100",
isActive

View File

@ -0,0 +1,512 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { FormProvider, useForm, FieldValues, UseFormReturn } from "react-hook-form";
import { TooltipProvider } from "@/ui";
import {
CollisionDetection,
DndContextProps,
DragEndEvent,
KeyboardCoordinateGetter,
KeyboardSensor,
} from "@dnd-kit/core";
import {
CurrencyData,
Language,
IGetQuote_Response_DTO,
ensureUpdateQuote_Request_DTOIsValid,
} from "@shared/contexts";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import es from "@/locales/es.json";
import { QuoteDetailsCardEditor } from "./QuoteDetailsCardEditor";
import { quoteUpdatePayload } from "../../chapterEditor";
// Use the installed React DOM test primitives; @testing-library/dom is not
// installed in this workspace, so no dependency installation is needed.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
HTMLElement.prototype.scrollIntoView = () => {};
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
let root: ReturnType<typeof createRoot>;
let container: HTMLDivElement;
const within = (scope: HTMLElement) => ({
getByText: (text: string): HTMLElement => {
const matches = Array.from(scope.querySelectorAll<HTMLElement>("*")).filter(
(element) => element.textContent?.trim() === text
);
const found =
matches.find((element) => element.tagName === "BUTTON") ?? matches[matches.length - 1];
if (!found) throw new Error(`Missing text: ${text}`);
return found;
},
getByLabelText: (label: string): HTMLElement => {
const direct = Array.from(scope.querySelectorAll<HTMLElement>("[aria-label]")).find(
(element) => element.getAttribute("aria-label") === label
);
const labelElement = Array.from(scope.querySelectorAll("label")).find(
(element) => element.textContent === label
);
const found =
direct ?? (labelElement?.htmlFor ? document.getElementById(labelElement.htmlFor) : null);
if (!found) throw new Error(`Missing label: ${label}`);
return found;
},
getByRole: (role: string) => {
const found = scope.querySelector<HTMLElement>(`[role="${role}"]`);
if (!found) throw new Error(`Missing role: ${role}`);
return found;
},
queryByRole: (role: string) =>
scope.querySelector(role === "textbox" ? "input,textarea" : `[role="${role}"]`),
});
const screen = {
...within(document.body),
findByText: async (text: string) => {
await act(async () => {
await Promise.resolve();
});
return within(document.body).getByText(text);
},
};
const fireEvent = {
click: (element: HTMLElement) => act(() => element.click()),
keyDown: (element: HTMLElement, event: KeyboardEventInit) =>
act(() => {
element.dispatchEvent(
new KeyboardEvent("keydown", { ...event, bubbles: true, cancelable: true })
);
}),
focus: (element: HTMLElement) => act(() => element.focus()),
change: (element: HTMLElement, event: { target: { value: string } }) =>
act(() => {
const prototype =
element.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(element, event.target.value);
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
}),
};
const render = (element: React.ReactNode) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root.render(element));
};
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
let currentForm: UseFormReturn<FieldValues>;
let dragProps: DndContextProps;
jest.mock("@dnd-kit/core", () => {
const actual = jest.requireActual("@dnd-kit/core");
return {
...actual,
DndContext: (props: DndContextProps) => {
dragProps = props;
return <>{props.children}</>;
},
useDroppable: () => ({ setNodeRef: () => {}, isOver: false }),
};
});
jest.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: () => {},
setActivatorNodeRef: () => {},
transform: null,
}),
verticalListSortingStrategy: {},
defaultAnimateLayoutChanges: () => false,
}));
jest.mock("@/components", () => {
const { useController } = jest.requireActual("react-hook-form");
const Text = ({ name }: { name: string }) => {
const { field } = useController({ name });
return <textarea aria-label={name} {...field} value={field.value ?? ""} />;
};
const Amount = ({ name, readOnly }: { name: string; readOnly?: boolean }) => {
const { field } = useController({ name });
return (
<input
aria-label={name}
readOnly={readOnly}
value={field.value?.amount ?? ""}
onChange={(event) => field.onChange({ ...field.value, amount: Number(event.target.value) })}
/>
);
};
return {
FormTextAreaField: Text,
FormCurrencyField: Amount,
FormQuantityField: Amount,
FormPercentageField: Amount,
ButtonGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DataTableColumnHeader: () => null,
DataTableRowActions: () => null,
};
});
jest.mock("@/lib/hooks", () => ({
DataTableProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock("@/ui/use-toast", () => ({ useToast: () => ({ toast: jest.fn() }) }));
jest.mock("../CatalogPickerDataTable", () => ({ CatalogPickerDataTable: () => null }));
jest.mock("./CatalogPickerDialog", () => ({
CatalogPickerDialog: ({
isOpen,
onSelect,
}: {
isOpen: boolean;
onSelect: (item: unknown) => void;
}) =>
isOpen ? (
<button
type='button'
onClick={() =>
onSelect({
id_article: "101",
description: "Catálogo",
retail_price: { amount: 5000, scale: 2, currency_code: "EUR" },
})
}
>
Elegir artículo de prueba
</button>
) : null,
}));
jest.mock("./BlocksPickerDialog/BlocksPickerDialog", () => ({ BlocksPickerDialog: () => null }));
const chapterA = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
const chapterB = "35ea9365-f3d3-4127-ab31-7be82dcdd006";
const item = (chapter_id: string | null = null) => ({
id_article: "",
description: "Panel",
chapter_id,
localized_description: "Solo proyección",
quantity: { amount: 100, scale: 2 },
unit_price: { amount: 10000, scale: 2, currency_code: "EUR" },
discount: { amount: 1000, scale: 2 },
subtotal_price: { amount: 10000, scale: 2, currency_code: "EUR" },
total_price: { amount: 9000, scale: 2, currency_code: "EUR" },
});
const currency = CurrencyData.createFromCode("EUR").object;
const language = Language.createFromCode("es").object;
function Harness({ grouped = false }: { grouped?: boolean }) {
const form = useForm({
defaultValues: {
status: "draft",
date: "2026-10-19",
reference: "Q-1",
customer_reference: "Customer",
customer_information: "Customer",
lang_code: "es",
currency_code: "EUR",
payment_method: "",
notes: "",
validity: "",
chapters: grouped
? [
{ id: chapterA, description: "Armario", subtotal_price: { amount: 9000 } },
{ id: chapterB, description: "Interior", subtotal_price: { amount: 0 } },
]
: [],
items: [item(grouped ? chapterA : null)],
show_line_discount_column: true,
show_chapter_line_amounts: true,
},
});
currentForm = form as unknown as UseFormReturn<FieldValues>;
return (
<TooltipProvider>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(() => {})}>
<QuoteDetailsCardEditor
currency={currency}
language={language}
defaultValues={{ items: [item()] }}
/>
<button type='submit'>Guardar prueba</button>
</form>
</FormProvider>
</TooltipProvider>
);
}
const snapshot = () => currentForm.getValues();
const drop = (from: string, to: string) =>
act(
() =>
dragProps.onDragEnd?.({
active: { id: from, rect: { current: { translated: null } } },
over: { id: to, rect: { top: 0, height: 0 } },
} as unknown as DragEndEvent)
);
beforeAll(async () => {
await i18n
.use(initReactI18next)
.init({ lng: "es", resources: { es }, interpolation: { escapeValue: false } });
});
it("starts free, cancels creation without changes and creates the first chapter with every detail", () => {
render(<Harness />);
expect(snapshot().chapters).toEqual([]);
fireEvent.click(screen.getByText("Añadir capítulo"));
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("Descripción"), { target: { value: " " } });
expect((within(dialog).getByText("Añadir") as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(within(dialog).getByText("Cancelar"));
expect(snapshot().chapters).toEqual([]);
fireEvent.click(screen.getByText("Añadir capítulo"));
fireEvent.change(screen.getByLabelText("Descripción"), { target: { value: "Mi armario" } });
fireEvent.click(screen.getByText("Añadir"));
expect(snapshot().items[0].chapter_id).toBe(snapshot().chapters[0].id);
expect(screen.getByText("Subtotal capítulo 1. Mi armario")).toBeTruthy();
});
it("updates the chapter footer from live values and prevents saving a whitespace title", async () => {
render(<Harness grouped />);
const footer = document.querySelector(`[data-chapter-footer="${chapterA}"]`)!;
expect(footer.textContent).toContain("90,00");
const quantityInput = screen.getByLabelText("items.0.quantity");
fireEvent.focus(quantityInput);
fireEvent.change(quantityInput, { target: { value: "200" } });
expect(document.activeElement).toBe(quantityInput);
expect(footer.textContent).toContain("180,00");
expect(within(footer as HTMLElement).queryByRole("textbox")).toBeNull();
fireEvent.change(screen.getByLabelText("Título del capítulo 1"), { target: { value: " " } });
fireEvent.click(screen.getByText("Guardar prueba"));
expect(await screen.findByText("La descripción del capítulo es obligatoria.")).toBeTruthy();
});
it("moves a whole chapter with its details, and details to an empty chapter", () => {
render(<Harness grouped />);
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
expect(snapshot().chapters.map((chapter: { id: string }) => chapter.id)).toEqual([
chapterB,
chapterA,
]);
expect(snapshot().items[0].chapter_id).toBe(chapterA);
expect((screen.getByLabelText("Título del capítulo 1") as HTMLTextAreaElement).value).toBe("Interior");
expect((screen.getByLabelText("Título del capítulo 2") as HTMLTextAreaElement).value).toBe("Armario");
fireEvent.change(screen.getByLabelText("Título del capítulo 1"), { target: { value: "Interior movido" } });
expect(snapshot().chapters[0].description).toBe("Interior movido");
const row = screen.getByLabelText("items.0.description").closest("tr")!;
drop(row.id, `empty:${chapterB}`);
expect(snapshot().items[0].chapter_id).toBe(chapterB);
expect(document.querySelector(`[data-chapter-footer="${chapterA}"]`)!.textContent).toContain(
"0,00"
);
});
it("duplicates a selected chapter once and can return to free mode preserving every detail", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
fireEvent.click(screen.getByLabelText("Seleccionar fila"));
fireEvent.click(screen.getByText("Duplicar"));
expect(snapshot().chapters).toHaveLength(3);
expect(snapshot().items).toHaveLength(2);
expect(snapshot().items[1].chapter_id).not.toBe(chapterA);
fireEvent.click(screen.getByText("Quitar todos los capítulos"));
fireEvent.click(screen.getByText("Quitar capítulos y conservar detalles"));
expect(snapshot().chapters).toEqual([]);
expect(snapshot().items).toHaveLength(2);
expect(
snapshot().items.every((entry: { chapter_id: string | null }) => entry.chapter_id === null)
).toBe(true);
});
it("adds catalog details to the chapter captured when opening the picker", () => {
render(<Harness grouped />);
fireEvent.focus(screen.getByLabelText("Título del capítulo 2"));
fireEvent.click(screen.getByText("Añadir artículo"));
fireEvent.focus(screen.getByLabelText("Título del capítulo 1"));
fireEvent.click(screen.getByText("Elegir artículo de prueba"));
expect(snapshot().items[1].chapter_id).toBe(chapterB);
});
it("cancels deletion, deletes empty chapters directly and confirms content deletion", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Eliminar capítulo 1"));
fireEvent.click(screen.getByText("Cancelar"));
expect(snapshot().items).toHaveLength(1);
fireEvent.click(screen.getByLabelText("Eliminar capítulo 2"));
expect(snapshot().chapters).toHaveLength(1);
fireEvent.click(screen.getByLabelText("Eliminar capítulo 1"));
fireEvent.click(screen.getByText("Quitar capítulos y conservar detalles"));
expect(snapshot().chapters).toEqual([]);
expect(snapshot().items).toHaveLength(1);
});
it("persists the PDF preference and serializes only writable chapter fields", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Mostrar importes de los detalles en el PDF"));
const payload = quoteUpdatePayload(snapshot() as IGetQuote_Response_DTO);
expect(payload.show_chapter_line_amounts).toBe(false);
expect(payload.chapters[0]).toEqual({ id: chapterA, description: "Armario" });
expect(payload.items[0]).not.toHaveProperty("localized_description");
expect(ensureUpdateQuote_Request_DTOIsValid(payload).isSuccess).toBe(true);
act(() => currentForm.reset(payload));
expect(snapshot().chapters).toHaveLength(2);
expect(screen.getByLabelText("Título del capítulo 1").getAttribute("aria-invalid")).toBe("false");
expect(document.querySelector(`[data-chapter-footer="${chapterA}"]`)!.textContent).toContain("90,00");
});
it("keeps inserted details inside their chapter and duplicates edited form values", () => {
render(<Harness grouped />);
fireEvent.change(screen.getByLabelText("items.0.description"), { target: { value: "Editado" } });
fireEvent.focus(screen.getByLabelText("items.0.description"));
fireEvent.click(screen.getByText(i18n.t("common.duplicate_row")));
expect(snapshot().items.map((entry: { description: string }) => entry.description)).toEqual([
"Editado",
"Editado",
]);
fireEvent.focus(screen.getByLabelText("items.1.description"));
fireEvent.click(screen.getByText(i18n.t("common.insert_row_below")));
expect(snapshot().items).toHaveLength(3);
expect(
snapshot().items.every((entry: { chapter_id: string }) => entry.chapter_id === chapterA)
).toBe(true);
});
it("transfers chapter content using the destination dialog", async () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Eliminar capítulo 1"));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown", code: "ArrowDown" });
await act(async () => {
await Promise.resolve();
});
fireEvent.keyDown(screen.getByRole("option"), { key: "Enter", code: "Enter" });
fireEvent.click(screen.getByText("Trasladar y conservar detalles"));
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
expect(snapshot().items[0].chapter_id).toBe(chapterB);
});
it("deletes chapter content only after explicit confirmation", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Eliminar capítulo 1"));
expect(snapshot().items).toHaveLength(1);
fireEvent.click(screen.getByText("Eliminar contenido seleccionado"));
expect(snapshot().items).toEqual([]);
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
});
it("disables mixed chapter/detail dragging without disabling selection actions", () => {
render(<Harness grouped />);
fireEvent.focus(screen.getByLabelText("Título del capítulo 2"));
fireEvent.click(screen.getByText("Añadir fila"));
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
const row = screen.getByLabelText("items.1.description").closest("tr")!;
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
expect(
screen.getByText("Para mover la selección, selecciona solo capítulos o solo detalles.")
).toBeTruthy();
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(true);
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([
chapterA,
chapterB,
]);
});
it("uses valid keyboard destinations and rejects pointer drops outside chapter contents", () => {
render(<Harness grouped />);
const rowId = screen.getByLabelText("items.0.description").closest("tr")!.id;
const ids = [`chapter:${chapterA}`, rowId, `chapter:${chapterB}`, `empty:${chapterB}`];
const rect = (top: number, height: number) => ({
top,
height,
bottom: top + height,
left: 0,
right: 100,
width: 100,
});
const rectangles = new Map([
[ids[0], rect(0, 200)],
[ids[1], rect(30, 20)],
[ids[2], rect(220, 100)],
[ids[3], rect(250, 20)],
]);
const containers = ids.map((id) => ({ id }));
const keyboardContext = {
droppableContainers: { getEnabled: () => containers },
droppableRects: rectangles,
over: null,
} as unknown as Parameters<KeyboardCoordinateGetter>[1]["context"];
const sensor = dragProps.sensors?.find((entry) => entry.sensor === KeyboardSensor);
const getter = (sensor?.options as { coordinateGetter: KeyboardCoordinateGetter })
.coordinateGetter;
expect(
getter(new KeyboardEvent("keydown", { code: "ArrowDown" }), {
active: ids[0],
currentCoordinates: { x: 0, y: 0 },
context: keyboardContext,
})
).toEqual({ x: 0, y: 170 });
expect(
getter(new KeyboardEvent("keydown", { code: "ArrowDown" }), {
active: rowId,
currentCoordinates: { x: 0, y: 0 },
context: keyboardContext,
})
).toEqual({ x: 0, y: 220 });
const collision = dragProps.collisionDetection!;
const args = {
active: { id: rowId },
droppableContainers: containers,
droppableRects: rectangles,
pointerCoordinates: { x: 1000, y: 1000 },
} as unknown as Parameters<CollisionDetection>[0];
expect(collision(args)).toEqual([]);
expect(
collision({ ...args, pointerCoordinates: { x: 10, y: 35 } }).map((entry) => entry.id)
).toEqual([rowId]);
});
it("folds chapter details visually without changing form data, edited values or PDF settings", () => {
render(<Harness grouped />);
fireEvent.change(screen.getByLabelText("items.0.description"), { target: { value: "Panel editado" } });
fireEvent.focus(screen.getByLabelText("items.0.description"));
const field = screen.getByLabelText("items.0.description") as HTMLTextAreaElement;
const row = field.closest("tr")!;
const actionsRow = row.nextElementSibling as HTMLTableRowElement;
const before = JSON.stringify(snapshot());
const payload = quoteUpdatePayload(snapshot() as IGetQuote_Response_DTO);
const footer = document.querySelector(`[data-chapter-footer="${chapterA}"]`) as HTMLTableRowElement;
const collapse = screen.getByLabelText("Plegar capítulo 1");
expect(collapse.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(collapse);
expect(row.hidden).toBe(true);
expect(actionsRow.hidden).toBe(true);
expect(footer.hidden).toBe(false);
expect(footer.textContent).toContain("90,00");
expect(field.isConnected).toBe(true);
expect(screen.getByLabelText("Desplegar capítulo 1").getAttribute("aria-expanded")).toBe("false");
expect(JSON.stringify(snapshot())).toBe(before);
expect(quoteUpdatePayload(snapshot() as IGetQuote_Response_DTO)).toEqual(payload);
fireEvent.click(screen.getByLabelText("Desplegar capítulo 1"));
expect(row.hidden).toBe(false);
expect(actionsRow.hidden).toBe(false);
expect(field.value).toBe("Panel editado");
});
it("keeps folding attached to the chapter when reordered and opens duplicates independently", async () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
expect(screen.getByLabelText("Desplegar capítulo 2").getAttribute("aria-expanded")).toBe("false");
expect(screen.getByLabelText("Plegar capítulo 1").getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText("items.0.description").closest("tr")!.hidden).toBe(true);
fireEvent.click(screen.getByLabelText("Duplicar capítulo 2"));
expect(snapshot().items).toHaveLength(2);
expect(screen.getByLabelText("items.0.description").closest("tr")!.hidden).toBe(true);
expect(screen.getByLabelText("items.1.description").closest("tr")!.hidden).toBe(false);
expect(screen.getByLabelText("Plegar capítulo 3").getAttribute("aria-expanded")).toBe("true");
await act(async () => { await Promise.resolve(); });
});

View File

@ -7,14 +7,18 @@ import {
import { DataTableProvider } from "@/lib/hooks";
import { cn } from "@/lib/utils";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui";
import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui";
import { useToast } from "@/ui/use-toast";
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
import { ColumnDef } from "@tanstack/react-table";
import { t } from "i18next";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useDetailColumns } from "../../hooks";
import { addChapter, EditorItem } from "../../chapterEditor";
import { useChapterEditor } from "../../hooks/useChapterEditor";
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
import { useDetailColumns } from "../../hooks/useDetailColumns";
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
import { QuoteItemsSortableDataTable, RowIdData } from "../QuoteItemsSortableDataTable";
import { BlocksPickerDialog } from "./BlocksPickerDialog/BlocksPickerDialog";
@ -30,6 +34,7 @@ export const QuoteDetailsCardEditor = ({
defaultValues: Readonly<{ [x: string]: any }> | undefined;
}) => {
const { toast } = useToast();
const { t } = useTranslation();
const { control, getValues, setValue, watch } = useFormContext();
const documentLanguageCode = watch("lang_code") || Language.DEFAULT_LANGUAGE_CODE;
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
@ -49,8 +54,14 @@ export const QuoteDetailsCardEditor = ({
name: "items",
});
const columns: ColumnDef<RowIdData, unknown>[] = useDetailColumns(
[
const chapterEditor = useChapterEditor(fieldActions.replace);
const [createChapterOpen, setCreateChapterOpen] = useState(false);
const appendPicked = useCallback((item: unknown) => {
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
else fieldActions.append(item);
}, [chapterEditor, fieldActions]);
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
/*{
id: "row_id" as const,
header: () => (
@ -194,7 +205,8 @@ export const QuoteDetailsCardEditor = ({
},
size: 150,
},
],
], [currency, language, t]);
const columns = useDetailColumns(detailColumns,
{
enableDragHandleColumn: true,
enableSelectionColumn: true,
@ -203,7 +215,7 @@ export const QuoteDetailsCardEditor = ({
const handleAppendCatalogArticle = useCallback(
(article: any, quantity: number = 1) => {
fieldActions.append({
appendPicked({
...article,
quantity: {
amount: 100 * quantity,
@ -220,12 +232,12 @@ export const QuoteDetailsCardEditor = ({
description: article.description,
});
},
[fieldActions]
[appendPicked, toast, t]
);
const handleAppendBlock = useCallback(
(block: any) => {
fieldActions.append({
appendPicked({
id_article: "",
description: `${block.title}\n${block.body}`,
quantity: {
@ -246,7 +258,7 @@ export const QuoteDetailsCardEditor = ({
description: block.title,
});
},
[fieldActions]
[appendPicked, toast, t]
);
const [isCollapsed, setIsCollapsed] = useState(false);
@ -254,25 +266,35 @@ export const QuoteDetailsCardEditor = ({
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
const pickCatalogArticle = () => { chapterEditor.capturePickerTarget(); setArticlePickerDialogOpen(true); };
const pickBlock = () => { chapterEditor.capturePickerTarget(); setBlockPickerDialogOpen(true); };
const onShowLineDiscountColumnChange = (value: boolean) => setValue("show_line_discount_column", value, { shouldDirty: true });
const table = chapterEditor.chapters.length ? (
<QuoteChaptersDataTable controller={chapterEditor} columns={columns} data={fields}
defaultItem={defaultValues?.items[0]} currencyCode={currency.code}
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
onAddChapter={() => setCreateChapterOpen(true)} pickCatalogArticle={pickCatalogArticle} pickBlock={pickBlock} />
) : (
<QuoteItemsSortableDataTable actions={{ ...fieldActions, getItem: (rowIndex) => getValues(`items.${rowIndex}`), pickCatalogArticle, pickBlock }}
columns={columns} data={fields} defaultValues={defaultValues}
extraControls={<Button type="button" variant="link" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange} />
);
const createChapterDialog = <QuoteChapterCreateDialog open={createChapterOpen} onOpenChange={setCreateChapterOpen}
onCreate={(description) => {
const before = chapterEditor.read();
const next = addChapter(before, description, chapterEditor.activeId);
chapterEditor.apply(next);
chapterEditor.focusTitle(next.chapters.find((chapter) => !before.chapters.some((entry) => entry.id === chapter.id))!.id);
}} />;
if (pickerMode === "dialog") {
return (
<div className='relative'>
<QuoteItemsSortableDataTable
actions={{
...fieldActions,
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
pickCatalogArticle: () => setArticlePickerDialogOpen(true),
pickBlock: () => setBlockPickerDialogOpen(true),
}}
columns={columns}
data={fields}
defaultValues={defaultValues}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={(value) =>
setValue("show_line_discount_column", value, { shouldDirty: true })
}
/>
{createChapterDialog}
{table}
<CatalogPickerDialog
onSelect={handleAppendCatalogArticle}
isOpen={articlePickerDialogOpen}
@ -295,6 +317,7 @@ export const QuoteDetailsCardEditor = ({
autoSaveId='uecko.quotes.details_layout'
className='items-stretch h-full'
>
{createChapterDialog}
<ResizablePanel
defaultSize={defaultLayout[0]}
collapsedSize={navCollapsedSize}
@ -309,20 +332,7 @@ export const QuoteDetailsCardEditor = ({
}}
className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")}
>
<QuoteItemsSortableDataTable
actions={{
...fieldActions,
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
}}
columns={columns}
data={fields}
defaultValues={defaultValues}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={(value) =>
setValue("show_line_discount_column", value, { shouldDirty: true })
}
/>
{table}
</ResizablePanel>
<ResizableHandle withHandle className='mx-3' />
<ResizablePanel defaultSize={defaultLayout[1]} minSize={10}>

View File

@ -26,6 +26,7 @@ import { useNavigate } from "react-router-dom";
import { QuotePricesResume } from "./components";
import { QuoteAdditionalCurrencyEditor, QuoteDetailsCardEditor, QuoteGeneralCardEditor } from "./components/editors";
import { useQuotes } from "./hooks";
import { quoteUpdatePayload } from "./chapterEditor";
export type QuoteDataForm = IGetQuote_Response_DTO;
export type QuoteDataFormItem = IGetQuote_QuoteItem_Response_DTO;
@ -64,6 +65,8 @@ export const QuoteEdit = () => {
notes: "",
validity: "",
show_line_discount_column: true,
chapters: [],
show_chapter_line_amounts: true,
additional_currency_code: null,
additional_currency_rate: null,
additional_currency_date: null,
@ -105,6 +108,7 @@ export const QuoteEdit = () => {
items: [
{
id_article: "",
chapter_id: null,
description: "",
quantity: {
amount: null,
@ -156,10 +160,6 @@ export const QuoteEdit = () => {
});
useEffect(() => {
if (!isDirty) {
return;
}
const { unsubscribe } = watch((formData, { name, type }) => {
if (name === "items") {
// Nueva fila agregada o fila eliminada o intercambiada
@ -186,11 +186,11 @@ export const QuoteEdit = () => {
// Recalcular los totales de la cotización
const quoteTotals = calculateQuoteTotals(formData, true);
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.subtotal_price)) {
setValue("subtotal_price", quoteTotals.subtotal_price.toObject());
}
if (!isEqual(quoteTotals.discount_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.discount_price.toObject(), formData.discount_price)) {
setValue("discount_price", quoteTotals.discount_price.toObject());
}
@ -215,6 +215,12 @@ export const QuoteEdit = () => {
throw currency.error;
}
setQuoteCurrency(currency.object);
// Line amounts use the quote currency; changing it keeps their numeric
// values, as the editor already does when changing the displayed symbol.
setValue("items", getValues("items").map((item) => ({
...item,
unit_price: { ...item.unit_price, currency_code: currency.object.code },
})), { shouldDirty: true });
}
if (name === "lang_code") {
@ -232,11 +238,11 @@ export const QuoteEdit = () => {
// Recalcular los totales de la cotización
const quoteTotals = calculateQuoteTotals(formData, true);
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.subtotal_price)) {
setValue("subtotal_price", quoteTotals.subtotal_price.toObject());
}
if (!isEqual(quoteTotals.discount_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.discount_price.toObject(), formData.discount_price)) {
setValue("discount_price", quoteTotals.discount_price.toObject());
}
@ -286,11 +292,11 @@ export const QuoteEdit = () => {
// Recalcular los totales de la cotización
const quoteTotals = calculateQuoteTotals(formData, true);
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.subtotal_price.toObject(), formData.subtotal_price)) {
setValue("subtotal_price", quoteTotals.subtotal_price.toObject());
}
if (!isEqual(quoteTotals.discount_price.toObject(), formData.total_price)) {
if (!isEqual(quoteTotals.discount_price.toObject(), formData.discount_price)) {
setValue("discount_price", quoteTotals.discount_price.toObject());
}
@ -311,16 +317,13 @@ export const QuoteEdit = () => {
}
});
return () => unsubscribe();
}, [watch, isDirty, setValue]);
}, [watch, setValue, getValues]);
const onSubmit = async (data: QuoteDataForm, shouldRedirect: boolean) => {
// Transformación del form -> typo de request
// localized_description is a server-side projection, never a writable snapshot field.
const payload = {
...data,
items: data.items.map(({ localized_description: _localizedDescription, ...item }) => item),
};
const payload = quoteUpdatePayload(data);
mutate(payload, {
onError: (error) => {
@ -351,7 +354,11 @@ export const QuoteEdit = () => {
// Reset form when data is fetched
useEffect(() => {
if (status === "success" && data) {
reset(data);
const currency = CurrencyData.createFromCode(data.currency_code);
const language = Language.createFromCode(data.lang_code);
if (currency.isSuccess) setQuoteCurrency(currency.object);
if (language.isSuccess) setQuoteLanguage(language.object);
reset({ ...data, chapters: data.chapters ?? [], show_chapter_line_amounts: data.show_chapter_line_amounts ?? true });
}
}, [status, data, reset]);

View File

@ -1,2 +1,3 @@
export * from "./useDetailColumns";
export * from "./useQuotes";
export * from "./useChapterEditor";

View File

@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from "react";
import { useFormContext, useWatch, UseFieldArrayReturn, FieldValues } from "react-hook-form";
import { ChapterEditorState, EditorItem, insertChapterItem } from "../chapterEditor";
const EMPTY_CHAPTERS: ChapterEditorState["chapters"] = [];
const EMPTY_ITEMS: EditorItem[] = [];
export function useChapterEditor(replace: UseFieldArrayReturn<FieldValues, "items">["replace"]) {
const { control, getValues, setValue, setFocus, clearErrors } = useFormContext();
const chapters: ChapterEditorState["chapters"] =
useWatch({ control, name: "chapters" }) ?? EMPTY_CHAPTERS;
const items: EditorItem[] = useWatch({ control, name: "items" }) ?? EMPTY_ITEMS;
const [activeId, setActiveId] = useState<string | null>(null);
const titleFocus = useRef<string | null>(null);
useEffect(() => {
const index = chapters.findIndex((chapter) => chapter.id === titleFocus.current);
if (index >= 0) {
setFocus(`chapters.${index}.description`);
titleFocus.current = null;
}
}, [chapters, setFocus]);
const focusTitle = (id: string) => {
titleFocus.current = id;
setActiveId(id);
};
const pickerTarget = useRef<string | null>(null);
const destination = () =>
chapters.some((chapter) => chapter.id === activeId)
? activeId
: chapters[chapters.length - 1]?.id ?? null;
const read = (): ChapterEditorState => ({
chapters: getValues("chapters") ?? [],
items: getValues("items") ?? [],
});
const apply = (state: ChapterEditorState) => {
// Strip response-only chapter subtotals and keep persistent ids distinct from RHF ids.
clearErrors("chapters");
setValue(
"chapters",
state.chapters.map(({ id, description }) => ({ id, description })),
{ shouldDirty: true }
);
replace(state.items);
};
const capturePickerTarget = () => {
pickerTarget.current = destination();
};
const appendPickedItem = (item: EditorItem) =>
apply(insertChapterItem(read(), item, pickerTarget.current));
return {
chapters,
items,
activeId,
setActiveId,
focusTitle,
destination,
read,
apply,
capturePickerTarget,
appendPickedItem,
};
}
export type ChapterEditorController = ReturnType<typeof useChapterEditor>;

View File

@ -19,7 +19,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
rowActionFn?: DataTablaRowActionFunction<TData>;
} = {}
): ColumnDef<TData, TValue>[] {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const {
enableDragHandleColumn = false,
enableSelectionColumn = false,
@ -30,8 +30,9 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
// const lastSelectedId = "";
return useMemo(() => {
const result = [...columns];
if (enableDragHandleColumn) {
columns.unshift({
result.unshift({
id: "row_drag_handle",
/*header: () => (
<UnfoldVertical aria-label='Mover fila' className='items-center justify-center w-4 h-4' />
@ -45,7 +46,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
}
if (enableSelectionColumn) {
columns.unshift({
result.unshift({
id: "select",
header: ({ table }) => (
<Checkbox
@ -65,7 +66,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
id={`select-row-${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={row.getToggleSelectedHandler()}
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
aria-label={t("common.select_row")}
className='mt-2'
/>
@ -77,7 +78,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
}
if (enableActionsColumn) {
columns.push({
result.push({
id: "row_actions",
/*header: () => (
<MoreHorizontalIcon
@ -94,6 +95,6 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
});
}
return columns;
}, [enableActionsColumn, enableDragHandleColumn, enableSelectionColumn, t, i18n.resolvedLanguage]);
return result;
}, [columns, enableActionsColumn, enableDragHandleColumn, enableSelectionColumn, rowActionFn, t]);
}

View File

@ -38,7 +38,7 @@ export const calculateQuoteTotals = (quote: any, force: boolean = false) => {
throw subtotalOrError.error;
}
const subtotalPrice = force
? calculateQuoteItemsTotals(quote.items).convertScale(2)
? calculateQuoteItemsTotals(quote.items, quote.currency_code).convertScale(2)
: subtotalOrError.object;
const discountPrice = subtotalPrice.percentage(discount.toNumber()).convertScale(2);
@ -60,8 +60,9 @@ export const calculateQuoteTotals = (quote: any, force: boolean = false) => {
};
};
export const calculateQuoteItemsTotals = (items: any[]) => {
export const calculateQuoteItemsTotals = (items: any[], currencyCode = "EUR") => {
let total_price = MoneyValue.create({
currencyCode,
amount: 0,
scale: 2,
}).object;
@ -69,7 +70,7 @@ export const calculateQuoteItemsTotals = (items: any[]) => {
items &&
items.map((item: any) => {
const quoteItemTotals = calculateQuoteItemTotals(item);
total_price = total_price.add(quoteItemTotals.total_price);
if (!quoteItemTotals.total_price.isNull()) total_price = total_price.add(quoteItemTotals.total_price);
});
return total_price;
@ -82,7 +83,7 @@ export const calculateQuoteItemTotals = (item: any) => {
if (quantityOrError.isFailure) {
throw quantityOrError.error;
}
const unitPriceOrError = MoneyValue.create(unit_price_dto);
const unitPriceOrError = MoneyValue.create({ ...unit_price_dto, currencyCode: unit_price_dto?.currency_code });
if (unitPriceOrError.isFailure) {
throw unitPriceOrError.error;
}
@ -135,7 +136,7 @@ export const calculateQuoteItemTotals = (item: any) => {
}
const quantity = quantityOrError.object;
const unitPriceOrError = MoneyValue.create(unit_price_dto);
const unitPriceOrError = MoneyValue.create({ ...unit_price_dto, currencyCode: unit_price_dto?.currency_code });
if (unitPriceOrError.isFailure) {
throw unitPriceOrError.error;
}
@ -147,8 +148,10 @@ export const calculateQuoteItemTotals = (item: any) => {
}
const discount = discountOrError.object;
const subtotalPrice = unitPrice.multiply(quantity.toNumber());
const totalPrice = subtotalPrice.subtract(subtotalPrice.percentage(discount.toNumber()));
const subtotalPrice = quantity.isNull() || unitPrice.isNull()
? MoneyValue.create({ amount: null, scale: 2, currencyCode: unit_price_dto?.currency_code }).object
: unitPrice.multiply(quantity.toNumber());
const totalPrice = subtotalPrice.isNull() ? subtotalPrice : subtotalPrice.subtract(subtotalPrice.percentage(discount.toNumber()));
return {
quantity,

View File

@ -42,6 +42,36 @@
"quotes": {
"downloading_dialog": {
"error": "No s’ha pogut descarregar el pressupost. Torna-ho a provar."
},
"chapters": {
"add": "Afegir capítol",
"create_description": "En crear el primer capítol s’agruparan tots els detalls existents.",
"description": "Descripció",
"title": "Títol del capítol {{number}}",
"title_required": "La descripció del capítol és obligatòria.",
"select": "Seleccionar capítol {{number}}",
"move": "Moure capítol {{number}}",
"collapse": "Plegar capítol {{number}}",
"expand": "Desplegar capítol {{number}}",
"duplicate": "Duplicar capítol {{number}}",
"remove": "Eliminar capítol {{number}}",
"subtotal": "Subtotal capítol {{number}}. {{description}}",
"empty": "Capítol buit. Afegeix detalls o arrossega’ls aquí.",
"show_amounts": "Mostrar imports dels detalls al PDF",
"mixed_selection": "Per moure la selecció, selecciona només capítols o només detalls.",
"drag_instructions": "Prem espai per iniciar el moviment, utilitza les fletxes amunt i avall i torna a prémer espai per deixar anar. Escapament cancel·la.",
"drag_start": "Moviment iniciat.",
"drag_over": "Destinació del moviment actualitzada.",
"drag_end": "Moviment finalitzat.",
"drag_cancel": "Moviment cancel·lat.",
"delete_title": "Eliminar selecció",
"delete_description": "La selecció afecta {{chapters}} capítols i {{details}} detalls. Pots eliminar el contingut o conservar-lo en treure un capítol.",
"destination": "Capítol de destinació",
"transfer": "Traslladar i conservar detalls",
"ungroup": "Treure tots els capítols",
"ungroup_keep": "Treure capítols i conservar detalls",
"delete_content": "Eliminar contingut seleccionat",
"ungroup_description": "Es trauran tots els capítols. Es conservaran els detalls, els imports i l’ordre."
}
},
"components": {

View File

@ -498,6 +498,36 @@
"desc": "Total price with percentage discount"
}
}
},
"chapters": {
"add": "Add chapter",
"create_description": "Creating the first chapter groups all existing details.",
"description": "Description",
"title": "Chapter {{number}} title",
"title_required": "A chapter description is required.",
"select": "Select chapter {{number}}",
"move": "Move chapter {{number}}",
"collapse": "Collapse chapter {{number}}",
"expand": "Expand chapter {{number}}",
"duplicate": "Duplicate chapter {{number}}",
"remove": "Delete chapter {{number}}",
"subtotal": "Subtotal chapter {{number}}. {{description}}",
"empty": "Empty chapter. Add details or drag them here.",
"show_amounts": "Show detail amounts in the PDF",
"mixed_selection": "To move the selection, select only chapters or only details.",
"drag_instructions": "Press space to start moving, use the up and down arrows, then press space again to drop. Escape cancels.",
"drag_start": "Movement started.",
"drag_over": "Movement destination updated.",
"drag_end": "Movement completed.",
"drag_cancel": "Movement cancelled.",
"delete_title": "Delete selection",
"delete_description": "The selection affects {{chapters}} chapters and {{details}} details. You can delete the content or keep it when removing a chapter.",
"destination": "Destination chapter",
"transfer": "Move and keep details",
"ungroup": "Remove all chapters",
"ungroup_keep": "Remove chapters and keep details",
"delete_content": "Delete selected content",
"ungroup_description": "All chapters will be removed. Details, amounts and order will be preserved."
}
},
"settings": {

View File

@ -498,6 +498,36 @@
"desc": "Importe total con el descuento ya aplicado"
}
}
},
"chapters": {
"add": "Añadir capítulo",
"create_description": "Al crear el primer capítulo se agruparán todos los detalles existentes.",
"description": "Descripción",
"title": "Título del capítulo {{number}}",
"title_required": "La descripción del capítulo es obligatoria.",
"select": "Seleccionar capítulo {{number}}",
"move": "Mover capítulo {{number}}",
"collapse": "Plegar capítulo {{number}}",
"expand": "Desplegar capítulo {{number}}",
"duplicate": "Duplicar capítulo {{number}}",
"remove": "Eliminar capítulo {{number}}",
"subtotal": "Subtotal capítulo {{number}}. {{description}}",
"empty": "Capítulo vacío. Añade detalles o arrástralos aquí.",
"show_amounts": "Mostrar importes de los detalles en el PDF",
"mixed_selection": "Para mover la selección, selecciona solo capítulos o solo detalles.",
"drag_instructions": "Pulsa espacio para iniciar el movimiento, usa las flechas arriba y abajo y vuelve a pulsar espacio para soltar. Escape cancela.",
"drag_start": "Movimiento iniciado.",
"drag_over": "Destino de movimiento actualizado.",
"drag_end": "Movimiento finalizado.",
"drag_cancel": "Movimiento cancelado.",
"delete_title": "Eliminar selección",
"delete_description": "La selección afecta a {{chapters}} capítulos y {{details}} detalles. Puedes eliminar el contenido o conservarlo cuando se quite un capítulo.",
"destination": "Capítulo de destino",
"transfer": "Trasladar y conservar detalles",
"ungroup": "Quitar todos los capítulos",
"ungroup_keep": "Quitar capítulos y conservar detalles",
"delete_content": "Eliminar contenido seleccionado",
"ungroup_description": "Se quitarán todos los capítulos. Se conservarán los detalles, sus importes y su orden."
}
},
"settings": {

View File

@ -498,6 +498,36 @@
"desc": "Montant total après application de la remise"
}
}
},
"chapters": {
"add": "Ajouter un chapitre",
"create_description": "La création du premier chapitre regroupe tous les détails existants.",
"description": "Description",
"title": "Titre du chapitre {{number}}",
"title_required": "La description du chapitre est obligatoire.",
"select": "Sélectionner le chapitre {{number}}",
"move": "Déplacer le chapitre {{number}}",
"collapse": "Replier le chapitre {{number}}",
"expand": "Déplier le chapitre {{number}}",
"duplicate": "Dupliquer le chapitre {{number}}",
"remove": "Supprimer le chapitre {{number}}",
"subtotal": "Sous-total du chapitre {{number}}. {{description}}",
"empty": "Chapitre vide. Ajoutez des détails ou faites-les glisser ici.",
"show_amounts": "Afficher les montants des détails dans le PDF",
"mixed_selection": "Pour déplacer la sélection, sélectionnez uniquement des chapitres ou uniquement des détails.",
"drag_instructions": "Appuyez sur espace pour commencer, utilisez les flèches haut et bas, puis appuyez à nouveau sur espace pour déposer. Échap annule.",
"drag_start": "Déplacement commencé.",
"drag_over": "Destination du déplacement mise à jour.",
"drag_end": "Déplacement terminé.",
"drag_cancel": "Déplacement annulé.",
"delete_title": "Supprimer la sélection",
"delete_description": "La sélection concerne {{chapters}} chapitres et {{details}} détails. Vous pouvez supprimer le contenu ou le conserver en retirant un chapitre.",
"destination": "Chapitre de destination",
"transfer": "Déplacer et conserver les détails",
"ungroup": "Retirer tous les chapitres",
"ungroup_keep": "Retirer les chapitres et conserver les détails",
"delete_content": "Supprimer le contenu sélectionné",
"ungroup_description": "Tous les chapitres seront retirés. Les détails, leurs montants et leur ordre seront conservés."
}
},
"settings": {

View File

@ -2,6 +2,7 @@ import { createInstance } from "i18next";
import es from "./es.json";
import en from "./en.json";
import fr from "./fr.json";
import ca from "./ca.json";
import esBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/es.json";
import enBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/en.json";
import frBlocks from "../app/quotes/components/editors/BlocksPickerDialog/locales/fr.json";
@ -56,3 +57,16 @@ describe("translation resources", () => {
}
});
});
describe("chapter translations", () => {
it.each([en, fr, ca])("includes every chapter label and interpolation variable", (resource) => {
const reference = flatten(es.translation.quotes.chapters);
const translated = flatten(resource.translation.quotes.chapters);
expect(Object.keys(translated).sort()).toEqual(Object.keys(reference).sort());
for (const [key, value] of Object.entries(reference)) {
expect(translated[key].trim()).not.toBe("");
expect(variables(translated[key])).toEqual(variables(value));
}
});
});

View File

@ -0,0 +1,313 @@
# Capítulos en cotizaciones: auditoría y plan
Fecha: 19 de octubre de 2026. Estado: fases 1 a 5 implementadas y probadas sin conexión a base de datos, incluida generación real de PDF con Chromium. La aplicación y validación del esquema en MariaDB siguen pendientes de un entorno autorizado; todavía no se considera completada la funcionalidad de extremo a extremo.
## Requisitos confirmados
- Las cotizaciones nuevas comienzan sin capítulos. Las antiguas siguen pudiéndose consultar, editar, duplicar y exportar sin convertirlas.
- Una cotización puede tener cero capítulos o uno o más capítulos. Si tiene capítulos, todos sus detalles pertenecen a uno de ellos, incluidos los bloques de texto.
- El capítulo tiene una cabecera donde solo se edita su descripción y un pie con su subtotal automático, sin campos editables.
- Un botón en la barra de herramientas de detalles permite añadir capítulos.
- Al mover un capítulo se desplazan todos sus detalles, conservando el orden interior.
- El PDF soporta ambos modos. Con capítulos se puede mostrar el desglose económico de los detalles o mostrar únicamente sus totales de capítulo, conservando las descripciones.
## Fase 1: decisiones funcionales
Las reglas de esta sección y de los apartados «Comportamiento del editor» e «Informe PDF» constituyen la especificación funcional de partida para las fases 2 a 6. Se concretan los valores propuestos en el plan de actuación; cualquier cambio posterior debe actualizar sus casos de aceptación.
### Modos, títulos e importes
- La cotización empieza en modo libre, sin capítulos. Leer, editar o exportar una cotización antigua no activa la agrupación.
- Crear el primer capítulo cambia toda la cotización al modo agrupado en una única operación: todos sus detalles, incluidos los textos, pasan al capítulo. No existen estados intermedios guardables con detalles huérfanos.
- Se permiten capítulos vacíos y capítulos que solo contienen texto. Ambos muestran subtotal de cero en la moneda de la cotización.
- La descripción del capítulo es obligatoria: se rechaza un valor vacío o compuesto solo por espacios al crear o guardar. Puede coincidir con la de otro capítulo; la identidad no depende del título. Cancelar el alta no modifica los detalles ni el modo.
- Los capítulos se numeran automáticamente desde 1 según su orden. La cabecera muestra «1. Descripción» y el pie «Subtotal capítulo 1. Descripción», con sus etiquetas traducidas. La numeración no es editable ni forma parte de la descripción guardada.
- La cabecera solo permite editar la descripción. Sus botones de selección, movimiento y acciones no representan campos de datos editables. El subtotal se muestra en el pie y no se repite como campo monetario editable en la cabecera.
- No se incluyen capítulos anidados. El plegado visual del editor se incorpora por petición posterior: los capítulos empiezan desplegados; el botón de flecha oculta sus detalles y acciones, conservando título y subtotal visibles. Este estado local se mantiene al reordenar el capítulo y no se guarda ni se envía al PDF. Los capítulos duplicados empiezan desplegados.
- El subtotal suma los totales de sus líneas tras sus descuentos individuales y antes del descuento general e impuestos. No hay descuentos, impuestos ni moneda propios del capítulo. Se conservan las reglas actuales de precisión y redondeo.
- Agrupar, desagrupar o mover contenido conserva los valores de las líneas y el total general. La visibilidad del PDF tampoco modifica importes ni los oculta en el editor.
### Capítulo activo y destinos
- Crear un capítulo, activar su cabecera o interactuar con uno de sus detalles lo convierte en el capítulo activo. La selección múltiple no cambia por sí sola ese destino.
- Añadir filas, artículos o bloques los inserta al final del capítulo activo; si no hay activo, al final del último capítulo. El selector de catálogo o bloques conserva el destino al abrirse, también si permite varias inserciones.
- Insertar encima o debajo de un detalle mantiene su capítulo, incluso si se trata de la primera o última línea. El pie no es un destino de inserción independiente.
- Eliminar el capítulo activo deja activo el capítulo anterior; si no existe, el siguiente. Al quitar todos los capítulos deja de haber capítulo activo.
### Selección, movimiento y acciones en grupo
- La cabecera selecciona el capítulo como unidad completa. El pie no se selecciona, edita, duplica, elimina ni arrastra por separado.
- Arrastrar un capítulo seleccionado entre varios capítulos seleccionados mueve esas unidades completas, manteniendo su orden relativo y el de sus detalles. El destino es un límite entre capítulos, nunca el interior de otro.
- Arrastrar detalles seleccionados los inserta consecutivamente en el destino y conserva su orden visual relativo. Si cambia el capítulo, cambia también su pertenencia. Se puede dejar vacío el capítulo de origen.
- Los destinos de detalle son posiciones dentro de un capítulo, incluido un capítulo vacío; no se permite soltar detalles antes de la primera cabecera ni fuera del último capítulo.
- Si la selección mezcla capítulos con detalles de otros capítulos, se deshabilita su arrastre conjunto y se explica el motivo. Los detalles pertenecientes a capítulos seleccionados ya están incluidos en esas unidades y no cuentan como selección mixta.
- Duplicar una selección coloca las copias de capítulos después del último capítulo seleccionado y las copias de detalles después del último detalle seleccionado de su respectivo capítulo. Se conserva el orden relativo. Los hijos de un capítulo seleccionado se procesan una sola vez.
- Duplicar capítulos copia títulos y contenido; la numeración se recalcula y las identidades son nuevas. Duplicar detalles mantiene su capítulo y los valores actuales del formulario, incluidos cambios aún no guardados.
- Borrar una selección con contenido requiere confirmación e indica los capítulos y detalles afectados, sin contar hijos dos veces. Borrar solo detalles conserva los capítulos, aunque queden vacíos.
- Las mismas restricciones de pertenencia y movimiento se aplican con ratón, teclado y pantalla táctil.
### Borrado y vuelta al modo libre
- Eliminar un capítulo vacío no requiere un diálogo adicional. Si era el último, la cotización vuelve al modo libre.
- Para eliminar un capítulo con contenido, el diálogo permite cancelar, eliminar capítulo y contenido o conservar el contenido trasladándolo a un capítulo no seleccionado para borrar. El traslado añade las líneas al final del destino, conservando el orden visual de origen.
- Si no queda otro capítulo de destino, la opción de conservación será «Quitar capítulos y conservar detalles»: elimina la agrupación completa y mantiene todas las líneas en su orden visual. Así se puede quitar el último capítulo sin perder contenido.
- La acción «Quitar todos los capítulos» está disponible siempre que haya capítulos y pide confirmación. Conserva todos los detalles y sus valores, elimina todas las pertenencias y vuelve al modo libre. Cancelar no produce cambios.
- Las operaciones afectan al formulario y marcan cambios sin guardar; solo el guardado las persiste. Cancelar un diálogo no modifica datos. Se mantiene el aviso existente al abandonar con cambios pendientes.
### Criterios de cierre de la fase
La fase 1 entrega reglas y escenarios para implementación, sin incorporar todavía código de dominio, interfaz ni persistencia. Los siguientes resultados quedan fijados:
| Caso | Acción | Resultado exigido |
| --- | --- | --- |
| F1-01 | Añadir el primer capítulo a tres detalles libres | Los tres pertenecen al nuevo capítulo en el mismo orden; no cambia el total |
| F1-02 | Cancelar el primer capítulo o introducir solo espacios | Cancelar conserva el modo libre; el título inválido impide completar el alta |
| F1-03 | Añadir un segundo capítulo y un artículo | El segundo se crea vacío y activo; el artículo se incorpora a él |
| F1-04 | Mover un capítulo con artículos y textos | Se mueve la unidad completa; la numeración se actualiza y sus detalles mantienen el orden |
| F1-05 | Mover el último detalle de un capítulo a otro vacío | El origen queda vacío con subtotal cero; el destino recalcula su subtotal; el total general no cambia |
| F1-06 | Borrar el último capítulo conservando contenido | Desaparece la agrupación y quedan todas las líneas libres con sus valores y orden |
| F1-07 | Duplicar o borrar un capítulo y alguno de sus hijos seleccionados | El hijo se procesa una sola vez; nunca queda una referencia huérfana |
| F1-08 | Ocultar importes individuales y guardar | Editor con precios visibles; PDF con cantidades, descripciones y subtotales de capítulo, sin precios ni descuentos individuales |
| F1-09 | Quitar capítulos, guardar y volver a agrupar | La preferencia PDF se conserva, se ignora en modo libre y vuelve a aplicarse en modo agrupado |
| F1-10 | Editar una cotización antigua sin añadir capítulos | Se mantiene el flujo libre y la plantilla de exportación sin capítulos |
Estos escenarios serán verificados mediante pruebas en las fases de implementación; su definición no implica que la aplicación ya los soporte.
## Auditoría inicial del código
Las rutas citadas son relativas a la raíz del repositorio. Esta tabla describe el estado previo a las fases de implementación; sus entregas se documentan al final.
| Área | Código actual | Consecuencia para los capítulos |
| --- | --- | --- |
| Especificación inicial | `doc/readme.adoc` exige conceptos libres y orden de los detalles; no define capítulos | La agrupación constituye una ampliación del modelo actual |
| Dominio | `server/src/contexts/sales/domain/entities/Quotes/Quote.ts`, `QuoteItem.ts` | `Quote` contiene una colección plana; `QuoteItem` contiene artículo, descripción, cantidad, precio y descuento. No hay pertenencia ni entidad capítulo |
| Cálculos | `QuoteItem.subtotalPrice/totalPrice`, `Quote._calculateTotalPriceItems`; `client/src/lib/calc.ts` | El subtotal de cotización suma los totales de línea después del descuento de línea; luego aplica descuento general e impuesto |
| DTOs | `shared/lib/contexts/sales/application/dto/Quote/` | Crear, obtener, actualizar y duplicar tienen contratos propios que habrá que extender de forma coherente |
| Validación | DTOs con Joi y `Quote.create` | Joi admite campos desconocidos; `Quote.create` tiene pendientes las reglas de negocio. Añadir campos al formulario no garantiza la integridad en servidor |
| Casos de uso | `server/src/contexts/sales/application/Quote/{CreateQuote,UpdateQuote,DuplicateQuote}.useCase.ts` | Crear y actualizar reconstruyen las líneas; duplicar crea nuevas entidades de detalle. Hay que transportar capítulos y reasignar sus referencias al duplicar |
| Persistencia | `server/src/contexts/sales/infrastructure/sequelize/{quote,quoteItem}.model.ts` | `quote_items` tiene clave compuesta `quote_id/item_id` y orden mediante `position`; no tiene capítulo |
| Repositorio y mapeo | `Quote.repository.ts`, `mappers/{quote,quoteItem}.mapper.ts` bajo la misma infraestructura | Obtener ordena por `position`; actualizar elimina y reinserta los detalles en una transacción. Los capítulos necesitan formar parte de esa misma escritura atómica |
| Presentación API | Presentadores de `createQuote`, `getQuote`, `updateQuote`, `duplicateQuote` | Cada uno mapea sus propias líneas. Es necesario cubrir todos; no basta con modificar GET |
| Alta SPA | `client/src/app/quotes/create.tsx` | Recoge datos generales y redirige al editor; no requiere introducir capítulos en el alta |
| Editor | `client/src/app/quotes/edit.tsx`, `components/editors/QuoteDetailsCardEditor.tsx` | React Hook Form usa `items` como lista plana mediante `useFieldArray`; recalcula importes al cambiar campos o estructura |
| Tabla y acciones | `QuoteItemsSortableDataTable.tsx`, `QuoteItemsSortableDataTableToolbar.tsx`, `QuoteItemsInlineRowActions.tsx` | TanStack Table y dnd-kit mueven filas e incluyen selección múltiple, inserción, duplicado y borrado. Todas esas operaciones necesitan respetar pertenencia |
| Artículos y textos | Selectores de catálogo y bloques en `QuoteDetailsCardEditor.tsx` | Actualmente añaden al final de la lista; en modo capítulos deben tener un capítulo de destino |
| Proveedor de datos | `client/src/app/quotes/hooks/useQuotes.tsx` | Las operaciones actuales pueden seguir usando `useDataSource` y las claves existentes; no hacen falta llamadas HTTP desde componentes |
| PDF | `ReportQuote.controller.ts`, `reporter/ReportQuote.reporter.ts`, `reporter/templates/quote/template.hbs` | La ruta `GET /quotes/:quoteId/report` carga la cotización guardada; Handlebars recorre `items` y Puppeteer genera el PDF |
| Preferencias de informe | `show_line_discount_column` atraviesa dominio, DTOs, modelo, editor y plantilla | Existe un patrón cercano para persistir la nueva preferencia de visibilidad de importes |
| Traducciones | `GetQuote.presenter.ts`, `resolveLocalizedQuoteDescriptions.ts` y reporter | Las descripciones de catálogo tienen proyección por idioma; no debe alterarse esa lógica al agrupar ni traducirse el título libre como si fuera un artículo |
Las rutas de cotizaciones ya usan `checkUser`, `getDealerMiddleware` y `handleRequest`. Se extenderán los contratos existentes manteniendo estos controles, sin crear endpoints independientes para editar capítulos.
## Modelo propuesto
Mantener `items` como lista de detalles y añadir una colección `chapters` al agregado `Quote`. Cada capítulo tendrá identidad propia y descripción. Cada detalle incorporará `chapter_id`, nullable en el modo sin capítulos. La pertenencia será explícita, no inferida por encontrar una cabecera anterior.
Contrato orientativo, adicional a los campos actuales:
```ts
chapters: Array<{ id: string; description: string }>;
items: Array<ExistingQuoteItem & { chapter_id: string | null }>;
show_chapter_line_amounts: boolean;
```
- El orden de `chapters` define el orden exterior. El orden de `items` define el orden de los detalles dentro de cada capítulo; serializar la lista plana en orden de capítulos y después de detalles.
- El servidor devuelve además el subtotal calculado por capítulo en el DTO de respuesta. El request no acepta ese subtotal como fuente de verdad.
- No guardar el pie como `QuoteItem`: produciría una línea monetaria adicional y permitiría duplicar, mover o editar un subtotal como un concepto normal.
- No añadir un booleano `has_chapters`: se deriva de la colección y evita estados contradictorios.
- El identificador del capítulo debe ser estable entre cargas y guardados. Diferenciarlo de los identificadores internos que genera `useFieldArray`; no usar su `id` interno como identidad persistente.
- No es necesario cambiar la identidad pública de los detalles para esta funcionalidad; hoy los DTOs no exponen `item_id` y actualizar reconstruye las entidades.
### Persistencia propuesta
- Nueva tabla `quote_chapters`: `quote_id`, `chapter_id`, `position`, `description`. Clave compuesta por cotización y capítulo, con borrado en cascada desde la cotización.
- `quote_items.chapter_id` nullable e indexado junto con `quote_id`. La referencia debe apuntar al capítulo de la misma cotización; considerar FK compuesta además de la validación de dominio.
- `quotes.show_chapter_line_amounts`: booleano con valor por defecto `true`.
- Persistir posiciones a partir de los arrays, como ya hace el mapper de detalles; no pedirlas también en el request.
- Cargar capítulos y detalles con orden explícito por posición. Mantener la consulta de listado sin cargar asociaciones innecesarias.
- Crear y actualizar cabecera, capítulos y detalles en la misma transacción. Con el reemplazo actual, eliminar primero detalles, después capítulos, insertar capítulos y finalmente detalles; no ejecutar en paralelo operaciones dependientes de las FK.
- Los registros históricos conservan `chapter_id = null` y ningún capítulo. No crear capítulos automáticamente al leerlos.
La preparación y aplicación del cambio de esquema será una tarea de implementación/despliegue expresamente autorizada. Esta auditoría no ejecuta migraciones ni arranca el backend. El arranque actual usa `sync({ alter: true })`: antes de desplegar hay que revisar su efecto sobre la nueva tabla, columnas, asociaciones y FK en una base aislada. No dar por validado el esquema solo porque TypeScript compile.
## Reglas de dominio y compatibilidad
1. Con `chapters.length === 0`, todos los detalles tienen `chapter_id = null`.
2. Con uno o más capítulos, cada detalle tiene exactamente un `chapter_id` existente en esa misma cotización. Rechazar referencias desconocidas, capítulos duplicados y pertenencias a otra cotización.
3. No hay capítulos anidados en esta primera versión. La descripción es el único dato editable de la cabecera; la numeración, si se muestra, se deriva de la posición.
4. El subtotal de capítulo suma el `totalPrice` de sus detalles, con descuento de línea aplicado, antes del descuento general y los impuestos. Usar `MoneyValue` y las mismas escalas y reglas de redondeo que en los cálculos actuales.
5. Los textos sin importe no contribuyen al subtotal. Los títulos y pies nunca contribuyen al total de cotización.
6. Agrupar, desagrupar, cambiar el orden o alternar la visibilidad del informe no cambia el total general.
7. Validar estructura en DTO/Joi y pertenencia en el agregado antes de persistir. Devolver errores con el patrón actual, sin corregir silenciosamente una estructura inválida enviada por API.
8. En creación y lectura antigua, campos nuevos ausentes equivalen a `chapters: []`, `chapter_id: null` y visibilidad `true`.
9. Para PUT antiguos sobre cotizaciones sin capítulos, aceptar que falten los nuevos campos. Para una cotización ya agrupada, rechazar un PUT que omita la estructura de capítulos: el cliente antiguo no conoce la agrupación y podría perderla. La eliminación de agrupación debe enviar explícitamente `chapters: []` y todos los detalles sin pertenencia. La preferencia de informe omitida en actualización conserva el valor guardado.
10. Duplicar una cotización o un capítulo crea nuevos identificadores de capítulo y remapea los detalles; conserva orden, descripciones y preferencia PDF.
La compatibilidad se refiere a conservar datos y flujos antiguos; no a permitir que un cliente antiguo elimine accidentalmente capítulos que no puede representar.
## Comportamiento del editor
Estas decisiones quedan fijadas en la fase 1 y se complementan con las reglas de selección, destinos y borrado anteriores:
| Operación | Comportamiento definido |
| --- | --- |
| Añadir el primer capítulo | Solicitar descripción y agrupar en él todos los detalles existentes, conservando su orden. En una cotización vacía, crear un capítulo vacío |
| Añadir otro capítulo | Crear un capítulo vacío después del activo, o al final si no hay capítulo activo; llevar el foco a su descripción |
| Añadir fila, artículo o bloque | Insertar en el capítulo activo; si no hay activo, usar el último. En modo sin capítulos mantener el comportamiento actual |
| Insertar encima o debajo de un detalle | Mantener el capítulo del detalle de referencia, también en sus extremos |
| Mover capítulo | Moverlo como unidad con todos sus detalles. Solo aceptar destinos entre capítulos; conservar el orden interior |
| Mover detalles | Reordenar dentro de su capítulo o trasladar a otro, actualizando pertenencia y orden en una operación. Incluir destino en capítulos vacíos; no permitir soltarlos fuera de capítulo |
| Seleccionar capítulo | Seleccionar su unidad completa para acciones de grupo. Si también se seleccionaron hijos, procesarlos una sola vez |
| Selección múltiple de detalles | Mantener la operación actual; en un traslado entre capítulos, todos los seleccionados pasan al destino conservando orden relativo |
| Selección mixta de capítulos y detalles sueltos | Deshabilitar el arrastre mixto inicialmente con explicación traducida; permite evitar movimientos ambiguos sin quitar la selección múltiple de detalles |
| Duplicar detalle | Duplicarlo en el mismo capítulo usando los valores actuales del formulario |
| Duplicar capítulo | Duplicar cabecera y contenido completo con nueva identidad y pertenencias remapeadas |
| Eliminar capítulo vacío | Eliminarlo; si era el último, volver al modo sin capítulos |
| Eliminar capítulo con contenido | Confirmar eliminación de contenido o trasladarlo a otro capítulo; sin destino disponible, permitir quitar la agrupación conservando detalles. Nunca dejar detalles huérfanos |
| Quitar todos los capítulos | Acción explícita que conserva los detalles y su orden visual, elimina sus referencias y vuelve al modo libre; también disponible con un único capítulo |
La tabla mostrará una cabecera diferenciada, detalles y pie por capítulo, siguiendo la referencia visual. Solo la descripción de la cabecera tendrá control de edición; el pie será texto monetario calculado y no tendrá selección, arrastre ni menú de acciones.
Reutilizar las celdas actuales para detalles, los diálogos y botones existentes y dnd-kit. Generar una proyección visual de cabeceras/detalles/pies a partir de los datos; esa proyección no se envía como `items` ni se incorpora al cálculo general. Mantener correspondencia explícita entre índices visuales y los índices de `items` del formulario.
Centralizar las operaciones de agrupación en el feature de cotizaciones, reutilizando el formulario y los hooks actuales. Los subtotales deben reaccionar a cantidades, precios, descuentos, altas, bajas y traslados; leer valores observados del formulario, no solo `fields`, que no refleja por sí solo cada edición de celda. El guardado enviará capítulos y pertenencias junto al resto de la cotización. Mantener aviso de cambios sin guardar.
## Informe PDF
Decisión de fase 1: la visibilidad de importes es una preferencia global por cotización, no por capítulo ni por descarga. Se guardará como `show_chapter_line_amounts`, siguiendo el patrón de `show_line_discount_column`. Un control en el editor permitirá «Mostrar importes de los detalles en el PDF» cuando haya capítulos. Valor inicial `true`, independiente de la visibilidad de descuentos y sin ocultar los importes en el editor. Quitar capítulos conserva esta preferencia para una agrupación posterior; duplicar la cotización también la conserva.
- Sin capítulos: conservar el informe actual e ignorar esta preferencia.
- Con capítulos y desglose: mostrar cabecera, detalles con sus importes y pie con subtotal.
- Con capítulos sin desglose: mostrar cantidades y descripciones de detalle, ocultando precio unitario, subtotal, porcentaje de descuento e importe total de cada detalle; mostrar el subtotal del capítulo. Evitar columnas monetarias vacías y ajustar `colspan` y cabeceras. El descuento general, los impuestos y el total de la cotización siguen en el resumen final.
- El resumen de descuentos de línea existente se mantiene como dato global según `show_line_discount_column`; nunca debe revelar precios ni porcentajes de detalles individuales cuando se oculte el desglose.
- Usar el mismo cálculo de capítulo en API y reporter, sin sumar cadenas formateadas ni confiar en subtotales enviados desde el navegador.
- Adaptar `ReportQuote.reporter.ts` y la plantilla única `template.hbs` para ambas estructuras. No duplicar plantillas completas.
- Añadir etiquetas de capítulo y subtotal a los idiomas documentales reales (`es`, `en`, `fr`). Los títulos libres se imprimen como texto escapado; conservar la resolución localizada de artículos existente.
- La preferencia persistida llega tanto a previsualización como a descarga mediante la ruta actual; no es necesario añadir parámetros de URL. La previsualización actual usa datos guardados, por lo que el cambio requiere guardar para reflejarse en el informe.
- Verificar PDF de varias páginas: cabecera junto al primer detalle cuando quepa, pie junto al último, cabeceras de tabla repetidas y capítulos largos capaces de partirse. No aplicar `break-inside: avoid` al capítulo completo.
Las cantidades y descripciones permanecen visibles en ambos modos del PDF. Con desglose, la columna de descuentos depende de `show_line_discount_column`; sin desglose, no se muestran descuentos individuales aunque esa preferencia esté activada. La cabecera del capítulo muestra número y descripción y su pie muestra el subtotal, tanto con desglose como sin él.
## Secuencia de implementación
1. **Reglas funcionales — completada.** Modos libre/agrupado, títulos, subtotales, destinos, selección, movimientos, duplicado, borrado y preferencia PDF definidos en este documento, con escenarios F1-01 a F1-10.
2. **Dominio y contratos compartidos — completada.** `QuoteChapter`, pertenencia y preferencia incorporados al dominio; DTOs y validadores ampliados. Presentadores de las cuatro respuestas adaptados al contrato. Pruebas de invariantes, subtotales, compatibilidad antigua y respuestas API.
3. **Backend y persistencia — implementación completada.** Modelos, asociaciones, mappers, repositorio transaccional y casos de uso adaptados. Lectura/escritura, rechazo de estructura inválida y duplicado con remapeo probados con dobles. Cambio de esquema preparado en `doc/capitulos-esquema.md`, sin aplicación a una base existente ni certificación de integración MariaDB.
4. **Editor — implementación completada.** Botón de alta, cabecera editable, pie calculado, destinos de inserción y movimiento de capítulos/detalles. Selección, duplicado, borrado, traslado y vuelta al modo libre cubiertos mediante pruebas del formulario, con sensores de arrastre simulados. Pendiente la inspección en navegador y la integración con una API/base autorizadas.
5. **PDF — completada.** Preferencia guardada, proyección del reporter, plantilla de ambos modos y traducciones. Pruebas HTML de contenido y ausencia de importes individuales, más generación e inspección de PDF multipágina con recursos locales. La validación integrada con datos guardados en MariaDB sigue en fase 6.
6. **Regresión y entrega.** Tests acotados, lint y compilación de paquetes afectados. Con entorno aislado autorizado, verificar esquema y ampliar E2E para crear, guardar, recargar, reordenar, duplicar y exportar ambos modos. Desplegar esquema/backend compatibles antes del cliente y evitar revertir a un backend que ignore capítulos después de empezar a guardarlos.
Cada etapa tendrá pruebas de su comportamiento; la funcionalidad no se considera completa hasta cubrir editor, API, persistencia y exportación conjuntamente.
## Casos de aceptación prioritarios
- Cotización antigua: abrir, editar, guardar, duplicar y exportar conserva líneas, orden y totales, sin capítulos creados automáticamente.
- Cotización nueva: comienza sin capítulos y permite seguir trabajando de ese modo.
- Crear primer capítulo con detalles existentes no pierde ninguna línea ni deja conceptos fuera.
- Dos capítulos con artículos y bloques de texto: guardado y recarga conservan agrupación, descripciones y orden.
- Editar cantidad, precio o descuento actualiza pie y resumen; mover detalles recalcula ambos capítulos sin cambiar el total general.
- Ejemplo: líneas con total 90 € y 50 € dan capítulo de 140 €; otra línea de 60 € da capítulo de 60 €. Subtotal general de 200 €; descuento general del 10 % e impuesto del 21 % producen 217,80 €. Reordenar u ocultar precios mantiene estos resultados.
- Capítulo vacío: pie de 0 en la moneda de la cotización; capítulo solo con textos: subtotal de 0, sin inventar importes para los textos.
- Arrastre por ratón, táctil y teclado mueve el capítulo completo; arrastre de detalles entre capítulos y hacia capítulo vacío mantiene pertenencia válida.
- Duplicado y borrado individual o múltiple no procesan hijos dos veces, no reutilizan identidades de capítulo y no dejan huérfanos.
- La API rechaza pertenencia desconocida, de otra cotización, capítulos duplicados y combinación de detalles libres con capítulos; una actualización inválida no cambia datos persistidos.
- Un fallo al escribir capítulos o detalles revierte toda la transacción.
- PDF sin capítulos y PDF con capítulos en ambos modos; combinar con descuentos visibles/ocultos, moneda adicional e idiomas documentales. Probar líneas con precios distintivos para verificar su ausencia real en el HTML sin desglose.
- PDF largo: ningún contenido desaparece; pies y encabezados quedan legibles tras los saltos de página.
## Validación realizada y hallazgos previos
### Entrega de fase 5
- `ReportQuote.reporter.ts`: proyección ordenada de cabeceras, detalles y pies. Los subtotales proceden de `Quote.getChapterSubtotal`, después del descuento de línea y antes del descuento general e impuestos. Sin capítulos se ignora `show_chapter_line_amounts`. Con capítulos y preferencia desactivada, los importes individuales tampoco se incorporan a la proyección del informe.
- `template.hbs`: una sola tabla para ambos modos. Cabeceras numeradas, pies con subtotal y texto escapado; modo sin desglose con únicamente cantidad y descripción, sin columnas económicas vacías. El resumen conserva descuentos globales, impuestos y moneda adicional. Se mantienen las traducciones de artículos y sus descripciones de respaldo.
- Etiqueta de subtotal de capítulo en los idiomas documentales `es`, `en` y `fr`, incluida en la validación de etiquetas requeridas. No se amplía el selector de idiomas documentales.
- Impresión: cabecera de tabla repetida, títulos unidos al primer detalle y pies al último cuando caben; capítulos largos y bloques de texto pueden continuar en otras páginas. Colores de capítulo preservados al imprimir, importes sin partir y resumen económico sin división entre páginas cuando cabe.
- Correcciones necesarias en el reporter: eliminar la espera de navegación tras `setContent` y cerrar Chromium en `finally`; excluir líneas de texto del acumulador de descuentos para evitar mezclar monedas nulas; imprimir el porcentaje de descuento general como cadena formateada y mostrar la moneda base real en la leyenda de cambio, que antes decía siempre `1 EUR`.
- Pruebas automatizadas: compatibilidad sin capítulos, visibilidad de importes y descuentos, orden y subtotales, capítulos vacíos y de texto, EUR/USD y moneda adicional, idiomas, escape de títulos/descripciones, traducción de artículos y cierre del navegador tanto en éxito como en fallo.
- Validación: `cd server && yarn test --runInBand src/contexts/sales` (10 suites, 72 pruebas), `yarn tsc --noEmit --pretty false`, ESLint acotado al reporter y sus pruebas y `git diff --check`. ESLint usa rutas de parser corregidas por argumento para producción y `project: null` para tests, excluidos por el tsconfig heredado; los tests sí comprueban tipos mediante ts-jest. No se cambia la configuración de lint en esta fase.
- PDF reales generados mediante `ReportQuotePresenter.toPDF`: sin capítulos (1 página), con desglose (1), sin desglose (1), capítulo de 90 líneas en ambos modos (9 páginas por modo), doce capítulos en límites de página (7) y bloque de texto de 150 líneas (5). Comprobación de texto con `pdftotext`: todas las líneas una sola vez, cabeceras repetidas en páginas con detalles, doce títulos junto al primer detalle y doce pies junto al último. Inspección visual de la muestra con desglose y de la última página con varios capítulos.
- Muestras temporales: `/tmp/uecko-chapter-pdf/`, generadas sin iniciar la API ni conectar a MariaDB. Para una verificación reproducible sin red se sustituyeron las peticiones al CSS externo por el CSS Tailwind ya compilado en `dist/client` y la imagen del pie por su copia local; no se modificó `dist`. La plantilla de producción conserva su dependencia previa del CDN de Tailwind 2 y del pie remoto; estas muestras no certifican la disponibilidad de esos recursos ni equivalencia visual exacta con ese CDN.
- No se ejecutan E2E contra la aplicación, operaciones de base de datos/Docker, instalaciones ni builds de distribución. El guardado real y la integración completa se reservan para fase 6 tras aplicar y comprobar el esquema en un entorno autorizado. Persiste una incoherencia previa ajena a capítulos: el bloque de condiciones usa `quote.default_legal_terms`, aunque el reporter expone `dealer.default_legal_terms`; no se modifica en esta fase.
### Entrega de fase 4
- Nuevo `client/src/app/quotes/chapterEditor.ts`: operaciones sobre capítulos y detalles, orden, duplicado sin doble procesamiento de hijos, traslado, borrado, desagrupación y cálculo de pies con `MoneyValue`. El guardado elimina de los capítulos sus subtotales de respuesta y de las líneas su descripción localizada de solo lectura.
- Nuevo hook `useChapterEditor.ts`: lectura de valores actuales de React Hook Form, capítulo activo, destino capturado al abrir selectores, aplicación de cambios con aviso de formulario modificado y foco del título al crear un capítulo. Los identificadores persistidos del capítulo no se confunden con los internos de `useFieldArray`.
- Nuevos `QuoteChapterCreateDialog.tsx` y `QuoteChaptersDataTable.tsx`: alta con título obligatorio, cabeceras numeradas, detalles con las celdas existentes, pies no editables, arrastre de unidades completas y detalles hacia capítulos vacíos o con contenido, selección, duplicado y diálogos de borrado/traslado. La selección mixta deshabilita el arrastre conjunto y evita procesar dos veces los hijos de un capítulo seleccionado.
- `QuoteDetailsCardEditor.tsx` integra ambos modos y conserva la tabla anterior para cotizaciones sin capítulos. El botón «Añadir capítulo» aparece en su barra de herramientas. Filas nuevas, artículos y bloques respetan el capítulo activo o el destino capturado al abrir el selector.
- La barra de herramientas, acciones de fila, filas arrastrables y `useDetailColumns.tsx` reutilizan los componentes existentes. Se ha corregido el evento de selección de Radix Checkbox; las columnas conservan sus celdas durante la escritura y se actualizan cuando cambia la moneda o el idioma.
- `edit.tsx` conserva defaults compatibles, serializa la estructura y guarda la preferencia PDF. El watcher se suscribe desde el inicio para recalcular también la primera modificación; subtotal y descuento se comparan con sus campos correspondientes. Al cargar se recupera la moneda/idioma reales y al cambiar la moneda se mantienen los valores numéricos de las líneas bajo la nueva moneda, sin añadir conversión de divisas.
- `client/src/lib/calc.ts` conserva la moneda de las líneas y de la cotización y excluye importes nulos, manteniendo el criterio del backend para líneas incompletas y textos. Los pies no entran en el cálculo general.
- Nuevos textos en `es`, `en`, `ca` y `fr`: títulos, acciones, confirmaciones, preferencia PDF y mensajes de accesibilidad del arrastre.
- La preferencia «Mostrar importes de los detalles en el PDF» queda guardada, pero su efecto en la plantilla pertenece a la fase 5. No se ha modificado todavía el reporter ni la plantilla.
Validaciones de fase 4:
- Seis suites del editor y operaciones: 27 pruebas correctas, incluidas 12 pruebas de integración React Hook Form con componentes reales de capítulos. Cubren creación/cancelación, título obligatorio, subtotales en vivo y mantenimiento del foco, movimiento de bloques, traslado a capítulo vacío, duplicado de selección, inserción en extremos, borrado/traslado, regreso al modo libre, preferencia y payload compatible con el validador compartido. El arrastre, sus destinos de teclado y sus colisiones se simulan; no son E2E en navegador.
- Traducciones: seis comprobaciones correctas de claves e interpolaciones, incluidas las de capítulos en catalán. La suite completa de recursos se ejecutó y conserva un fallo previo de bloques franceses: falta `CAP_SEP_AMBIENTES` y aparecen `CAP_INSTALACION`/`CAP_SUMINISTRO` respecto a español e inglés. Esos ficheros de bloques no se modifican en esta fase. La ejecución acotada de claves/textos excluye las dos comprobaciones de bloques.
- `cd client && yarn tsc --noEmit --pretty false` correcto. ESLint acotado sin errores; permanecen advertencias del código anterior por `any` y contexto exportado junto a componentes. La configuración tenía una severidad inválida `"none"`: se cambia por `"off"` en `client/.eslintrc.cjs` para poder ejecutar ESLint.
- Se sustituyó el import de `FormLabel` desde el barrel global en la barra por `Label` de UI, con su asociación al switch. Esto evita cargar indirectamente `react-pdf` en su prueba y resuelve el fallo previo de Jest con `pdfjs-dist` en esa suite.
- No se instalaron dependencias: los tests de formulario usan React DOM, porque falta `@testing-library/dom` en el entorno. No se ejecutaron E2E ni inspección visual: el navegador Chromium de Playwright no está instalado. No se arrancó el backend ni se aplicó el esquema; el guardado/recarga se comprueba mediante payload, validador y reset del formulario, junto a las pruebas de persistencia de fase 3.
- No se ejecutó el build de distribución para evitar recrear `dist/client`; se comprobó TypeScript sin emitir. Tampoco se ejecutó lint global con `--max-warnings 0`, dado el historial de advertencias fuera de estos cambios.
### Entrega de fase 3
- Modelos `quote.model.ts`, `quoteItem.model.ts` y nuevo `quoteChapter.model.ts`: capítulos asociados a cotización, posiciones, pertenencia nullable e índice de detalles, preferencia PDF con default `true`. Barrel de modelos actualizado.
- Nuevo `quoteChapter.mapper.ts` y ampliación de mappers de cotización y detalle: lectura de capítulos ordenados, validación del propietario, pertenencias y preferencia. La escritura genera posiciones consecutivas y orden canónico por capítulos, conservando el orden interior de detalles y textos.
- `Quote.repository.ts`: creación y reemplazo usan una transacción obligatoria; guardan cabecera, eliminan detalles antes que capítulos e insertan capítulos antes que detalles. La lectura carga ambas asociaciones por separado y con orden explícito; la búsqueda de última cotización conserva también capítulos y transacción.
- Crear y actualizar reconstruyen entidades de capítulo mediante `createQuoteChapters.ts`, validan la estructura incluso al invocarse sin controlador y asignan pertenencias a las líneas. Los casos antiguos sin capítulos siguen siendo válidos.
- La actualización carga la cotización con bloqueo `FOR UPDATE` dentro de la transacción de escritura; rechaza omitir capítulos si el registro ya está agrupado. La desagrupación exige una estructura explícita sin pertenencias. Omitir la preferencia PDF mantiene el valor persistido.
- Duplicar genera nuevos capítulos y detalles, reasigna pertenencias a la cotización nueva y conserva títulos, orden, importes y preferencia PDF. Se comprueba el fallo del resultado de dominio antes de persistir la copia.
- Pruebas de fase 3: casos de uso de creación, consulta, actualización y duplicado; mapeo en ambos sentidos; reemplazo y creación transaccionales; propagación de fallos en capítulos y detalles; metadatos y asociaciones Sequelize. El rollback se verifica con un doble transaccional y la clase de transacción existente, sin conexión a MariaDB.
- Validación: `cd server && yarn test --runInBand src/contexts/sales` (9 suites, 56 pruebas); `cd shared && yarn test --runInBand --runTestsByPath lib/contexts/sales/application/dto/Quote/QuoteChapter.dto.test.ts` (27 pruebas). TypeScript del servidor con `yarn tsc --noEmit --pretty false`; ESLint acotado a los archivos ejecutables de fase 3 usando las rutas de configuración corregidas por argumento, sin cambiar la configuración del repositorio.
- No se ejecutan backend, Docker, DDL, migraciones, instalaciones, builds de distribución ni E2E. La comprobación de esquema, FK compuesta, bloqueo y rollback real permanece documentada en `doc/capitulos-esquema.md` para integración autorizada. No hay cambios nuevos en la interfaz que requieran repetir pruebas del cliente en esta fase.
- Diferencia relevante de infraestructura: Sequelize no instala la FK compuesta de pertenencia desde los modelos definidos; la validación de aplicación sí está implementada. La restricción SQL y su convivencia con `sync({ alter: true })` quedan preparadas para revisión explícita antes de aplicar el esquema.
### Entrega de fase 2
- Dominio: nueva entidad `QuoteChapter`, con identidad, cotización propietaria y descripción obligatoria; `QuoteItem.chapterId`; `Quote.chapters`, `showChapterLineAmounts` y `getChapterSubtotal`. Se rechazan duplicados, capítulos de otra cotización y pertenencias inválidas. Sin capítulos se conservan los valores por defecto antiguos.
- Contratos: `QuoteChapter.dto.ts`, DTOs de crear, consultar, actualizar y duplicar y sus barrel exports. Los requests admiten omitir los campos para compatibilidad y no introducen defaults que confundan omisión con desagrupación explícita. Las respuestas incluyen capítulos con subtotal calculado, pertenencia nullable y preferencia PDF.
- Validadores de crear/actualizar: verifican estructura, UUID v4, títulos, unicidad, pertenencia y preferencia booleana. Rechazan datos de subtotal o anidación dentro de capítulos enviados como entrada. La comprobación de PUT antiguo frente a una cotización ya agrupada requiere cargar el registro y corresponde a la fase 3.
- Los cuatro presentadores API se adaptan ahora para satisfacer los contratos de respuesta requeridos. No hay cambios de esquema, repositorio ni casos de uso: estos todavía no reconstruyen ni guardan capítulos. Esta fase no constituye una versión desplegable de la funcionalidad completa.
- Los textos sin importe se excluyen de la suma general, al igual que de la suma de capítulos. Esto evita intentar sumar importes nulos en la moneda por defecto a una cotización de otra moneda.
- Tests de contratos en shared: 27 pruebas correctas. Tests de dominio y regresión en servidor: 24 pruebas correctas; tests de los cuatro presentadores: 8 pruebas correctas.
- TypeScript de servidor y cliente: `yarn tsc --noEmit --pretty false` correcto en ambos paquetes.
- ESLint acotado a los archivos ejecutables modificados del servidor: sin errores y con tres advertencias previas por parámetros `context` sin usar en presentadores. Para ejecutarlo se usaron `--ignore-path ../.gitignore` y un `tsconfigRootDir` absoluto mediante `--parser-options`: la configuración habitual intenta leer rutas relativas inexistentes desde `server/`. No se modifica esa configuración fuera del alcance.
- El test nuevo de shared importa las funciones de `@jest/globals` explícitamente: en este entorno `shared/node_modules/@types` no existe y el `typeRoots` del paquete impide resolver los globales Jest automáticamente. No se instalaron dependencias ni se cambió la configuración global de pruebas.
- `git diff --check` correcto. No se ejecutan builds de distribución, lint global, E2E ni operaciones de infraestructura: se comprueban tipos sin emitir y pruebas acotadas; la validación de esquema y flujos completos corresponde a fases posteriores.
### Auditoría y fase 1
Validación del cierre de fase 1: revisión de coherencia entre decisiones funcionales, tabla del editor, comportamiento PDF y escenarios F1-01 a F1-10. `git diff --no-index --check /dev/null doc/capitulos-cotizaciones.md` correcto. No se repiten tests, lint, builds ni E2E porque esta fase solo modifica la especificación; los resultados siguientes corresponden a la auditoría inicial y no verifican capítulos implementados.
Estado inicial: `git status --short` limpio. Auditoría estática de especificación, rutas, dominio, DTOs, persistencia, editor, cálculos, traducciones y exportación.
Comandos ejecutados desde cada paquete:
```bash
# server
yarn test --runInBand --runTestsByPath src/contexts/sales/application/Quote/UpdateQuote.useCase.test.ts src/contexts/sales/infrastructure/mappers/quote.mapper.test.ts src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.reporter.test.ts
# client
yarn test --runInBand --runTestsByPath src/app/quotes/components/QuoteItemsSortableDataTable.test.tsx src/app/quotes/components/QuoteItemsSortableDataTableToolbar.test.tsx src/app/quotes/components/QuoteItemsInlineRowActions.test.tsx
```
- Servidor: 3 suites y 12 pruebas correctas. Son pruebas con dobles de repositorio/mappers y renderizado HTML; no validan una base real ni generan PDF.
- Cliente: 2 suites correctas, 4 pruebas correctas; la suite de la barra de herramientas falla antes de ejecutar sus pruebas porque Jest no transforma el ESM de `pdfjs-dist`, importado indirectamente desde el barrel de componentes y `react-pdf`. Fallo previo a esta propuesta; no se ha modificado su configuración.
- No se ejecutaron builds ni lint: la entrega solo incorpora documentación y no cambia código ejecutable. No se ejecutó la batería completa.
- No se ejecutaron E2E, backend, Docker ni operaciones de base de datos: requieren infraestructura fuera del alcance de esta auditoría. No se instaló ninguna dependencia.
Inconsistencias observadas, sin cambios fuera del alcance:
- `AGENTS.md` indica que no se localizaron tests de servidor ni E2E; el repositorio actual sí contiene ambos y scripts Playwright en el paquete raíz.
- La interfaz carga `es/en/ca/fr`, mientras los selectores de idioma documental y el reporter admiten `es/en/fr`. Añadir textos de interfaz en los cuatro recursos; no asumir PDF catalán implementado.
- Los DTOs de línea tienen diferencias previas: crear declara `price`, actualizar/obtener usan `subtotal_price`, y el validador de actualización menciona `article_id` mientras el contrato y caso de uso usan `id_article`. Ampliar las pruebas de contrato sin normalizar campos ajenos a esta funcionalidad.
- El watcher de `edit.tsx` compara en varios puntos el subtotal o descuento calculado con `formData.total_price` en lugar del campo correspondiente. Revisar esa ruta al incorporar el recálculo de capítulos para no añadir escrituras redundantes; no se corrige en esta auditoría.
### Ampliación: plegado visual del editor
- `QuoteChaptersDataTable.tsx`: botón accesible con `aria-expanded` y etiquetas traducidas en `es/en/ca/fr`. Estado local por capítulo, vinculado a su identificador; no forma parte del formulario ni de los DTO. Conserva el título, subtotal y acciones del capítulo visibles.
- `QuoteItemsSortableTableRow.tsx`: soporte para filas ocultas. Los campos permanecen montados para conservar valores y validación; los detalles y destinos vacíos plegados se excluyen del movimiento de filas. Los capítulos completos siguen pudiendo desplazarse.
- `QuoteChapterEditor.test.tsx`: plegar/desplegar sin alterar datos ni payload, conservar campos editados y subtotal, ocultar acciones de fila, mantener plegado al reordenar y desplegar copias de forma independiente.
- Validación: 22 pruebas de editor y operaciones de capítulos, cinco comprobaciones de traducciones, TypeScript sin emisión y ESLint acotado (dos advertencias previas de contexto/`any` en la fila reutilizable). La suite de editor emite un aviso de actualización asíncrona de paginación fuera de `act`; las dos pruebas nuevas de plegado se ejecutan también aisladas y pasan sin ese aviso. No se modifican ni repiten las pruebas de servidor/PDF, ni se ejecutan E2E o builds de distribución: el cambio se limita al estado visual del cliente.

47
doc/capitulos-esquema.md Normal file
View File

@ -0,0 +1,47 @@
# Capítulos: cambio de esquema preparado para revisión
Estado: modelos implementados en fase 3; cambios no aplicados a ninguna base de datos. No se ha ejecutado una migración ni `sync`. Este documento describe la estructura requerida y las comprobaciones para su futura aplicación autorizada.
## Cambios concretos
| Tabla | Cambio | Datos históricos |
| --- | --- | --- |
| `quotes` | Añadir `show_chapter_line_amounts`, BOOLEAN, NOT NULL, DEFAULT TRUE | Mantener importes individuales visibles por defecto |
| `quote_chapters` | Crear tabla sin timestamps: `quote_id` UUID, `chapter_id` UUID, `position` MEDIUMINT NOT NULL, `description` TEXT NOT NULL | No insertar capítulos automáticamente |
| `quote_chapters` | PK compuesta (`quote_id`, `chapter_id`); índice `quote_chapters_position_idx` (`quote_id`, `position`) | Sin transformación de detalles existentes |
| `quote_chapters` | FK de `quote_id` a `quotes.id`, borrado en cascada | La asociación Sequelize declara esta relación |
| `quote_items` | Añadir `chapter_id` UUID nullable, DEFAULT NULL | Todos los detalles antiguos permanecen libres |
| `quote_items` | Añadir índice `quote_items_chapter_idx` (`quote_id`, `chapter_id`) | Sin alterar la posición de los detalles existentes |
Los modelos están en `server/src/contexts/sales/infrastructure/sequelize/{quote,quoteItem,quoteChapter}.model.ts`. El registro dinámico existente descubre el nuevo modelo y después configura las asociaciones. La declaración tipada `dealer_id` usa `ForeignKey` para representar la asociación ya existente; no introduce una columna adicional diferente.
## Pertenencia y claves foráneas
El dominio valida que cada referencia de detalle exista entre los capítulos de su cotización. El mapper comprueba también la cotización propietaria de los capítulos al leer. No se declara una asociación Sequelize desde `chapter_id` de detalle hacia una clave de capítulo aislada: la identidad persistida del capítulo es compuesta y una FK simple no garantizaría la pertenencia a la misma cotización.
La FK compuesta adicional a revisar al aplicar el esquema tiene esta forma:
```sql
ALTER TABLE quote_items
ADD CONSTRAINT quote_items_chapter_fk
FOREIGN KEY (quote_id, chapter_id)
REFERENCES quote_chapters (quote_id, chapter_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
```
Es una definición para revisión, no un script de migración ejecutado. Antes de aplicarla se deben contrastar los tipos físicos, collation, motor y restricciones existentes de las tablas reales. Con `chapter_id = NULL`, las líneas antiguas no requieren un capítulo. El repositorio elimina primero detalles y luego capítulos, por lo que el reemplazo completo respeta esta restricción.
**Límite actual:** el modelo Sequelize no representa ni instala esta FK compuesta; la garantía de pertenencia implementada ahora es de aplicación. No afirmar que la base de datos la impone hasta aplicarla y verificarla. El arranque existente ejecuta `sync({ force: false, alter: true })`; hay que verificar si conserva la FK manual y sus índices, también en un segundo arranque. No añadir DDL automático en hooks ni cambiar la estrategia global de sincronización dentro de esta fase.
## Comprobaciones para una aplicación autorizada
1. Trabajar con una copia aislada y recuperable del esquema existente; revisar las definiciones físicas de UUID, FK e índices antes de materializar el cambio.
2. Verificar tabla nueva, columnas, defaults e índices, incluyendo el comportamiento del `sync({ alter: true })` actual. Revisar por separado la FK compuesta anterior.
3. Leer, editar, guardar y duplicar una cotización anterior al cambio: cero capítulos, referencias null, mismo orden y mismos importes.
4. Crear una cotización con dos capítulos y textos; guardar y recargar. Reordenar capítulos y trasladar detalles verificando sus posiciones.
5. Inyectar un fallo al insertar capítulos y otro al insertar detalles. Comprobar en MariaDB que cabecera, capítulos y detalles permanecen en el estado previo, sin escrituras parciales.
6. Lanzar dos actualizaciones concurrentes: una que introduce capítulos y otra de un cliente antiguo que los omite. La comprobación bajo bloqueo debe evitar una eliminación por omisión si la agrupación ya se ha confirmado.
7. Probar la FK compuesta, si se aplica: rechazar pertenencias inexistentes o a otra cotización, permitir líneas libres null y permitir el reemplazo transaccional de estructura.
8. Desplegar el backend compatible antes del editor. Una vez guardados capítulos, no volver a un backend que los ignore ni retirar columnas/tablas sin conservar esos datos.
Las pruebas de fase 3 usan metadatos Sequelize y dobles transaccionales, sin abrir conexiones. Verifican contrato, mapeo, orden de operaciones, propagación de transacción y errores; no certifican el DDL, el bloqueo concurrente ni el rollback real de MariaDB. Estas comprobaciones quedan para el entorno autorizado de integración.

View File

@ -1,3 +1,5 @@
import { createQuoteChapters } from "../services/createQuoteChapters";
import { ensureQuoteChapterStructureIsValid } from "@shared/contexts";
import { ArticleIdentifier } from "@/contexts/catalog/domain";
import { IUseCase, IUseCaseError, UseCaseError } from "@/contexts/common/application";
import { IRepositoryManager } from "@/contexts/common/domain";
@ -71,6 +73,11 @@ export class CreateQuoteUseCase
);
}
const chapterValidation = ensureQuoteChapterStructureIsValid(request);
if (chapterValidation.isFailure) {
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, chapterValidation.error.message));
}
const catalogValidation = await this._validateCatalogItems(request.items ?? []);
if (catalogValidation.isFailure) return Result.fail(catalogValidation.error);
@ -234,6 +241,9 @@ export class CreateQuoteUseCase
return Result.fail(dateSentOrError.error);
}
const chapters = createQuoteChapters(quoteDTO.chapters, quoteId);
if (chapters.isFailure) return Result.fail(chapters.error);
// Items
let items: Collection<QuoteItem>;
@ -276,7 +286,10 @@ export class CreateQuoteUseCase
throw percentageOrError.error;
}
const chapterId = item.chapter_id ? UniqueID.create(item.chapter_id) : undefined;
if (chapterId?.isFailure) throw chapterId.error;
const quoteItemOrError = QuoteItem.create({
chapterId: chapterId?.object ?? null,
idArticle: idArticleOrError.object,
description: descriptionOrError.object,
quantity: quantityOrError.object,
@ -320,6 +333,8 @@ export class CreateQuoteUseCase
taxLabel: taxLabelOrError.object,
items,
chapters: chapters.object,
showChapterLineAmounts: quoteDTO.show_chapter_line_amounts ?? true,
dealerId,
dateSent: dateSentOrError.object,

View File

@ -12,6 +12,7 @@ import {
IQuoteRepository,
QuoteCustomerReference,
QuoteItem,
QuoteChapter,
QuoteReference,
QuoteStatus,
} from "../../domain";
@ -71,6 +72,9 @@ export class DuplicateQuoteUseCase
targetQuoteOrError = this._tryDuplicateQuoteInstance(sourceQuote, targetId, quoteReference);
if (targetQuoteOrError.isFailure) {
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, targetQuoteOrError.error.message));
}
await quoteRepo.create(targetQuoteOrError.object);
return Result.ok<Quote>(targetQuoteOrError.object);
});
@ -94,10 +98,30 @@ export class DuplicateQuoteUseCase
// items
let items: Collection<QuoteItem>;
const chapters: QuoteChapter[] = [];
const chapterIds = new Map<string, UniqueID>();
try {
for (const chapter of sourceQuote.chapters) {
const copy = QuoteChapter.create({ quoteId: targetId, description: chapter.description });
if (copy.isFailure) throw copy.error;
chapters.push(copy.object);
chapterIds.set(chapter.id.toString(), copy.object.id);
}
items = new Collection<QuoteItem>(
sourceQuote.items.toArray().map((item) => QuoteItem.create(item).object)
sourceQuote.items.toArray().map((item) => {
const chapterId = item.chapterId ? chapterIds.get(item.chapterId.toString()) : null;
const copy = QuoteItem.create({
idArticle: item.idArticle,
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
discount: item.discount,
chapterId,
});
if (copy.isFailure) throw copy.error;
return copy.object;
})
);
} catch (e: unknown) {
return Result.fail(e as IDomainError);
@ -125,6 +149,8 @@ export class DuplicateQuoteUseCase
taxLabel: sourceQuote.taxLabel,
items,
chapters,
showChapterLineAmounts: sourceQuote.showChapterLineAmounts,
dealerId,
dateSent,

View File

@ -0,0 +1,137 @@
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { ISalesContext } from "../../infrastructure/Sales.context";
import { Quote } from "../../domain";
import { UniqueID } from "@shared/contexts";
import { chapterA, chapterB, createDTO, dealer, mapper, quoteId, rawQuote, updateDTO } from "../../infrastructure/__fixtures__/quoteChapters.mock";
import { CreateQuoteUseCase } from "./CreateQuote.useCase";
import { UpdateQuoteUseCase } from "./UpdateQuote.useCase";
import { DuplicateQuoteUseCase } from "./DuplicateQuote.useCase";
import { GetQuoteUseCase } from "./GetQuote.useCase";
const id = UniqueID.create(quoteId).object;
const transaction = { marker: "transaction" };
const repo = {
getById: jest.fn(), create: jest.fn(), update: jest.fn(),
exists: jest.fn(), existsByReference: jest.fn(), findLastReferenceByDealerId: jest.fn(),
};
const repositoryFactory = jest.fn(() => repo);
const adapter = { startTransaction: () => ({
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(transaction),
}) } as unknown as ISequelizeAdapter;
const context = {
dealer, adapter,
repositoryManager: { getRepository: () => repositoryFactory },
} as unknown as ISalesContext;
beforeEach(() => {
jest.clearAllMocks();
repo.getById.mockResolvedValue(mapper.mapToDomain(rawQuote()));
repo.create.mockResolvedValue(undefined);
repo.update.mockResolvedValue(undefined);
repo.exists.mockResolvedValue(false);
repo.existsByReference.mockResolvedValue(false);
repo.findLastReferenceByDealerId.mockResolvedValue(null);
});
it("creates chapters and memberships without trusting submitted totals", async () => {
const result = await new CreateQuoteUseCase(context).execute(createDTO());
expect(result.isSuccess).toBe(true);
expect(repo.create).toHaveBeenCalledWith(result.object);
expect(repositoryFactory).toHaveBeenCalledWith({ transaction });
expect(result.object.chapters.map((chapter) => chapter.id.toString())).toEqual([chapterA, chapterB]);
expect(result.object.items.toArray().map((item) => item.chapterId?.toString())).toEqual([chapterA, chapterB, chapterB]);
expect(result.object.getChapterSubtotal(result.object.chapters[0].id).object.toPrimitive()).toBe(9000);
expect(result.object.totalPrice.toPrimitive()).toBe(16335);
expect(result.object.showChapterLineAmounts).toBe(false);
});
it("creates an old request without chapters in free mode", async () => {
const dto = createDTO(false);
delete dto.chapters;
delete dto.show_chapter_line_amounts;
dto.items.forEach((item) => delete item.chapter_id);
const result = await new CreateQuoteUseCase(context).execute(dto);
expect(result.isSuccess).toBe(true);
expect(result.object.chapters).toEqual([]);
expect(result.object.showChapterLineAmounts).toBe(true);
});
it("loads chapters through the existing get use case", async () => {
const result = await new GetQuoteUseCase(context).execute({ id });
expect(result.isSuccess).toBe(true);
expect(result.object.chapters).toHaveLength(2);
expect(repositoryFactory).toHaveBeenCalledWith({ transaction });
});
it("updates the full structure while preserving an omitted PDF preference", async () => {
const dto = updateDTO();
delete dto.show_chapter_line_amounts;
dto.chapters?.reverse();
const result = await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: dto });
expect(result.isSuccess).toBe(true);
expect(result.object.showChapterLineAmounts).toBe(false);
expect(result.object.chapters.map((chapter) => chapter.id.toString())).toEqual([chapterB, chapterA]);
expect(repo.getById).toHaveBeenCalledWith(id, { forUpdate: true });
expect(repo.update).toHaveBeenCalledWith(result.object);
});
it("rejects an old PUT that omits chapters on an already grouped quote", async () => {
const dto = updateDTO(false);
delete dto.chapters;
const result = await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: dto });
expect(result.isFailure).toBe(true);
expect(result.error.code).toBe("INVALID_INPUT_DATA");
expect(repo.update).not.toHaveBeenCalled();
});
it("accepts an old PUT on a free quote and explicit ungrouping on a grouped quote", async () => {
const dto = updateDTO(false);
const explicit = await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: dto });
expect(explicit.isSuccess).toBe(true);
expect(explicit.object.chapters).toEqual([]);
expect(explicit.object.items.toArray().every((item) => item.chapterId === null)).toBe(true);
repo.getById.mockResolvedValue(mapper.mapToDomain(rawQuote(false)));
delete dto.chapters;
expect((await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: dto })).isSuccess).toBe(true);
});
it.each(["create", "update"])("rejects orphan details before writing in %s", async (operation) => {
const dto = createDTO();
dto.items[0].chapter_id = UniqueID.generateNewID().object.toString();
const result = operation === "create"
? await new CreateQuoteUseCase(context).execute(dto)
: await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: { ...updateDTO(), items: dto.items.map((item) => ({ ...item, subtotal_price: item.price })) } });
expect(result.isFailure).toBe(true);
expect(result.error.code).toBe("INVALID_INPUT_DATA");
expect(repo.create).not.toHaveBeenCalled();
expect(repo.update).not.toHaveBeenCalled();
});
it.each([true, false])("duplicates grouped=%s with new identities and preserved values", async (grouped) => {
const source = mapper.mapToDomain(rawQuote(grouped));
repo.getById.mockResolvedValue(source);
const result = await new DuplicateQuoteUseCase(context).execute({ sourceId: id });
expect(result.isSuccess).toBe(true);
const copy: Quote = result.object;
expect(copy.id.equals(source.id)).toBe(false);
expect(copy.showChapterLineAmounts).toBe(false);
expect(copy.totalPrice.toPrimitive()).toBe(source.totalPrice.toPrimitive());
expect(copy.chapters.map((chapter) => chapter.description.toString())).toEqual(source.chapters.map((chapter) => chapter.description.toString()));
copy.chapters.forEach((chapter, index) => {
expect(chapter.id.equals(source.chapters[index].id)).toBe(false);
expect(chapter.quoteId.equals(copy.id)).toBe(true);
});
copy.items.toArray().forEach((item, index) => {
expect(item.id.equals(source.items.toArray()[index].id)).toBe(false);
expect(item.description.toString()).toBe(source.items.toArray()[index].description.toString());
if (grouped) expect(copy.chapters.some((chapter) => chapter.id.equals(item.chapterId!))).toBe(true);
else expect(item.chapterId).toBeNull();
});
});
it("returns a repository error when writing fails", async () => {
repo.update.mockRejectedValue(new Error("Write failed"));
const result = await new UpdateQuoteUseCase(context).execute({ id, quoteDTO: updateDTO() });
expect(result.isFailure).toBe(true);
expect(result.error.code).toBe("REPOSITORY_ERROR");
});

View File

@ -1,3 +1,5 @@
import { createQuoteChapters } from "../services/createQuoteChapters";
import { ensureQuoteChapterStructureIsValid } from "@shared/contexts";
import {
IUseCase,
IUseCaseError,
@ -5,7 +7,6 @@ import {
UseCaseError,
} from "@/contexts/common/application";
import { IRepositoryManager } from "@/contexts/common/domain";
import { IInfrastructureError } from "@/contexts/common/infrastructure";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import {
Collection,
@ -74,72 +75,76 @@ export class UpdateQuoteUseCase
const dealerId = this._dealer.id;
// Comprobar que existe el Quote
const existingQuote = await QuoteRepository().getById(id);
if (!existingQuote) {
const message = `Quote ID not found`;
return Result.fail(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
path: "id",
})
);
const chapterValidation = ensureQuoteChapterStructureIsValid(quoteDTO);
if (chapterValidation.isFailure) {
return Result.fail(UseCaseError.create(UseCaseError.INVALID_INPUT_DATA, chapterValidation.error.message));
}
const catalogValidation = await this._validateCatalogItems(quoteDTO.items ?? []);
if (catalogValidation.isFailure) return Result.fail(catalogValidation.error);
// //
// regla de negocio -> no poder modificar un quote enviado
// Crear quote
const quoteOrError = this._tryCreateQuoteInstance(
quoteDTO,
id,
dealerId,
existingQuote.showLineDiscountColumn,
existingQuote.taxLabel
);
if (quoteOrError.isFailure) {
const { error: domainError } = quoteOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
// Errores manuales
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "El presupuesto tiene algún dato erróneo.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
return this._updateQuote(quoteOrError.object);
}
private async _updateQuote(quote: Quote) {
// Guardar el contacto
const transaction = this._adapter.startTransaction();
const quoteRepository = this._getQuoteRepository();
let quoteRepo: IQuoteRepository;
try {
await transaction.complete(async (t) => {
quoteRepo = quoteRepository({ transaction: t });
await quoteRepo.update(quote);
});
return await transaction.complete(async (t) => {
const quoteRepo = QuoteRepository({ transaction: t });
// Comprobar que existe el Quote
const existingQuote = await quoteRepo.getById(id, { forUpdate: true });
if (!existingQuote) {
const message = `Quote ID not found`;
return Result.fail(
UseCaseError.create(UseCaseError.NOT_FOUND_ERROR, message, {
path: "id",
})
);
}
return Result.ok<Quote>(quote);
if ((existingQuote.chapters?.length ?? 0) > 0 && quoteDTO.chapters === undefined) {
return Result.fail(UseCaseError.create(
UseCaseError.INVALID_INPUT_DATA,
"Updating a grouped quote requires an explicit chapters structure",
{ path: "chapters" }
));
}
// //
// regla de negocio -> no poder modificar un quote enviado
// Crear quote
const quoteOrError = this._tryCreateQuoteInstance(
quoteDTO,
id,
dealerId,
existingQuote.showLineDiscountColumn,
existingQuote.taxLabel,
existingQuote.showChapterLineAmounts
);
if (quoteOrError.isFailure) {
const { error: domainError } = quoteOrError;
let errorCode = "";
let message = "";
switch (domainError.code) {
// Errores manuales
case DomainError.INVALID_INPUT_DATA:
errorCode = UseCaseError.INVALID_INPUT_DATA;
message = "El presupuesto tiene algún dato erróneo.";
break;
default:
errorCode = UseCaseError.UNEXCEPTED_ERROR;
message = domainError.message;
break;
}
return Result.fail(UseCaseError.create(errorCode, message, domainError));
}
await quoteRepo.update(quoteOrError.object);
return Result.ok<Quote>(quoteOrError.object);
});
} catch (error: unknown) {
const _error = error as IInfrastructureError;
return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, _error.message));
return Result.fail(UseCaseError.create(UseCaseError.REPOSITORY_ERROR, (error as Error).message));
}
}
@ -148,7 +153,8 @@ export class UpdateQuoteUseCase
quoteId: UniqueID,
dealerId: UniqueID,
existingShowLineDiscountColumn: boolean,
existingTaxLabel: Note
existingTaxLabel: Note,
existingShowChapterLineAmounts: boolean = true
): Result<Quote, IDomainError> {
const statusOrError = QuoteStatus.create(quoteDTO.status);
if (statusOrError.isFailure) {
@ -229,6 +235,9 @@ export class UpdateQuoteUseCase
return Result.fail(dateSentOrError.error);
}
const chapters = createQuoteChapters(quoteDTO.chapters, quoteId);
if (chapters.isFailure) return Result.fail(chapters.error);
// Items
let items: Collection<QuoteItem>;
@ -271,7 +280,10 @@ export class UpdateQuoteUseCase
throw percentageOrError.error;
}
const chapterId = item.chapter_id ? UniqueID.create(item.chapter_id) : undefined;
if (chapterId?.isFailure) throw chapterId.error;
const quoteItemOrError = QuoteItem.create({
chapterId: chapterId?.object ?? null,
idArticle: idArticleOrError.object,
description: descriptionOrError.object,
quantity: quantityOrError.object,
@ -313,6 +325,8 @@ export class UpdateQuoteUseCase
taxLabel: existingTaxLabel,
items,
chapters: chapters.object,
showChapterLineAmounts: quoteDTO.show_chapter_line_amounts ?? existingShowChapterLineAmounts,
dealerId,

View File

@ -0,0 +1,19 @@
import { Description, IDomainError, IQuoteChapter_Request_DTO, Result, UniqueID } from "@shared/contexts";
import { QuoteChapter } from "../../domain";
export function createQuoteChapters(
chapters: readonly IQuoteChapter_Request_DTO[] | undefined,
quoteId: UniqueID
): Result<QuoteChapter[], IDomainError> {
const result: QuoteChapter[] = [];
for (const chapter of chapters ?? []) {
const id = UniqueID.create(chapter.id);
if (id.isFailure) return Result.fail(id.error);
const description = Description.create(chapter.description);
if (description.isFailure) return Result.fail(description.error);
const entity = QuoteChapter.create({ quoteId, description: description.object }, id.object);
if (entity.isFailure) return Result.fail(entity.error);
result.push(entity.object);
}
return Result.ok(result);
}

View File

@ -1,2 +1,3 @@
export * from "./QuoteService";
export * from "./resolveLocalizedQuoteDescriptions";
export * from "./createQuoteChapters";

View File

@ -1,6 +1,7 @@
import {
AggregateRoot,
CurrencyData,
DomainError,
ICollection,
IDomainError,
Language,
@ -12,6 +13,7 @@ import {
UniqueID,
} from "@shared/contexts";
import { QuoteCustomer } from "./QuoteCustomer";
import { QuoteChapter } from "./QuoteChapter";
import { QuoteItem } from "./QuoteItem";
import { QuoteReference } from "./QuoteReference";
import { QuoteStatus } from "./QuoteStatus";
@ -33,6 +35,8 @@ export interface IQuoteProps {
additionalCurrencyDate?: UTCDateValue;
items: ICollection<QuoteItem>;
chapters?: readonly QuoteChapter[];
showChapterLineAmounts?: boolean;
//subtotalPrice: MoneyValue;
discount: Percentage;
@ -78,6 +82,9 @@ export interface IQuote {
totalPrice: MoneyValue;
items: ICollection<QuoteItem>;
chapters: readonly QuoteChapter[];
showChapterLineAmounts: boolean;
getChapterSubtotal(chapterId: UniqueID): Result<MoneyValue, IDomainError>;
dealerId: UniqueID;
@ -88,21 +95,45 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
public static create(props: IQuoteProps, id?: UniqueID): Result<Quote, IDomainError> {
const quote = new Quote(props, id);
// Reglas de negocio / validaciones
// ...
// ...
const chapterIds = new Set<string>();
for (const chapter of quote.chapters) {
const chapterId = chapter.id.toString();
if (!chapter.quoteId.equals(quote.id) || chapterIds.has(chapterId)) {
return Result.fail(DomainError.create(
DomainError.INVALID_INPUT_DATA,
"Chapters must be unique and belong to this quote",
{ path: "chapters" }
));
}
chapterIds.add(chapterId);
}
const invalidIndex = props.items.toArray().findIndex((item) =>
chapterIds.size > 0
? !item.chapterId || !chapterIds.has(item.chapterId.toString())
: item.chapterId !== null
);
if (invalidIndex !== -1) {
return Result.fail(DomainError.create(
DomainError.INVALID_INPUT_DATA,
"Every detail must reference a chapter of this quote when chapters exist",
{ path: `items.${invalidIndex}.chapter_id` }
));
}
return Result.ok<Quote>(quote);
}
protected _items: ICollection<QuoteItem>;
private readonly _chapters: readonly QuoteChapter[];
protected _subtotal: MoneyValue;
protected _calculateTotalPriceItems = (): MoneyValue => {
const result = this.props.items
.toArray()
.reduce<MoneyValue>(
(accumulator, currentItem) => accumulator.add(currentItem.totalPrice),
(accumulator, currentItem) => currentItem.totalPrice.isNull()
? accumulator
: accumulator.add(currentItem.totalPrice),
MoneyValue.create({ amount: 0, scale: 2, currencyCode: this.currency.code }).object
);
@ -113,6 +144,7 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
super(props, id);
this._items = Object.freeze(props.items);
this._chapters = Object.freeze([...(props.chapters ?? [])]);
}
get id(): UniqueID {
@ -195,6 +227,31 @@ export class Quote extends AggregateRoot<IQuoteProps> implements IQuote {
return this._items;
}
get chapters(): readonly QuoteChapter[] {
return this._chapters;
}
get showChapterLineAmounts(): boolean {
return this.props.showChapterLineAmounts ?? true;
}
public getChapterSubtotal(chapterId: UniqueID): Result<MoneyValue, IDomainError> {
if (!this.chapters.some((chapter) => chapter.id.equals(chapterId))) {
return Result.fail(DomainError.create(
DomainError.INVALID_INPUT_DATA,
"Chapter does not belong to this quote",
{ path: "chapter_id" }
));
}
const total = this.items.toArray().reduce((sum, item) =>
item.chapterId?.equals(chapterId) && !item.totalPrice.isNull()
? sum.add(item.totalPrice)
: sum,
MoneyValue.create({ amount: 0, scale: 2, currencyCode: this.currency.code }).object
);
return Result.ok(total);
}
get dealerId() {
return this.props.dealerId;
}

View File

@ -0,0 +1,143 @@
import { ArticleIdentifier } from "@/contexts/catalog/domain";
import {
Collection,
CurrencyData,
Description,
MoneyValue,
Language,
Note,
Percentage,
Quantity,
UniqueID,
UTCDateValue,
} from "@shared/contexts";
import { IQuoteProps, Quote } from "./Quote";
import { QuoteChapter } from "./QuoteChapter";
import { QuoteCustomer } from "./QuoteCustomer";
import { QuoteItem } from "./QuoteItem";
import { QuoteReference } from "./QuoteReference";
import { QuoteStatus } from "./QuoteStatus";
const quoteId = UniqueID.generateNewID().object;
const chapterA = QuoteChapter.create({ quoteId, description: Description.create("Armario").object }).object;
const chapterB = QuoteChapter.create({ quoteId, description: Description.create("Interior").object }).object;
const item = (chapterId: UniqueID | null, amount: number | null = 10000, discount = 0, currencyCode = "EUR") =>
QuoteItem.create({
chapterId,
idArticle: ArticleIdentifier.create().object,
description: Description.create("Concepto").object,
quantity: Quantity.create({ amount: amount === null ? null : 100, scale: 2 }).object,
unitPrice: MoneyValue.create({ amount, scale: 2, currencyCode }).object,
discount: Percentage.create({ amount: discount, scale: 2 }).object,
}).object;
const props = (overrides: Partial<IQuoteProps> = {}): IQuoteProps => ({
status: QuoteStatus.createDraft(),
date: UTCDateValue.createCurrentDate().object,
reference: QuoteReference.create("Q-1").object,
customerReference: QuoteReference.create("").object,
customer: QuoteCustomer.create("Customer").object,
language: Language.createDefaultCode().object,
currency: CurrencyData.createFromCode("EUR").object,
paymentMethod: Note.create("").object,
notes: Note.create("").object,
validity: Note.create("").object,
items: new Collection([]),
discount: Percentage.create({ amount: 1000, scale: 2 }).object,
tax: Percentage.create({ amount: 2100, scale: 2 }).object,
taxLabel: Note.create("% IVA").object,
dealerId: UniqueID.generateNewID().object,
dateSent: UTCDateValue.create(null).object,
...overrides,
});
describe("Quote chapters", () => {
it.each(["", " \n\t "])("rejects empty titles: %j", (description) => {
expect(QuoteChapter.create({ quoteId, description: Description.create(description).object }).isFailure).toBe(true);
});
it("keeps legacy quotes free and defaults PDF amounts to visible", () => {
const legacyItem = item(null);
const { chapterId: _chapterId, ...legacyProps } = {
idArticle: legacyItem.idArticle,
description: legacyItem.description,
quantity: legacyItem.quantity,
unitPrice: legacyItem.unitPrice,
discount: legacyItem.discount,
chapterId: null,
};
const legacy = QuoteItem.create(legacyProps).object;
const quote = Quote.create(props({ items: new Collection([legacy]) }), quoteId).object;
expect(legacy.chapterId).toBeNull();
expect(quote.chapters).toEqual([]);
expect(quote.showChapterLineAmounts).toBe(true);
expect(quote.subtotalPrice.toPrimitive()).toBe(10000);
});
it("rejects duplicate chapter identities and chapters owned by another quote", () => {
expect(Quote.create(props({ chapters: [chapterA, chapterA] }), quoteId).isFailure).toBe(true);
const foreign = QuoteChapter.create({
quoteId: UniqueID.generateNewID().object,
description: Description.create("Foreign").object,
}).object;
expect(Quote.create(props({ chapters: [foreign] }), quoteId).isFailure).toBe(true);
});
it("allows repeated titles when chapter identities differ", () => {
const sameTitle = QuoteChapter.create({ quoteId, description: chapterA.description }).object;
expect(Quote.create(props({ chapters: [chapterA, sameTitle] }), quoteId).isSuccess).toBe(true);
});
it.each([
{ chapters: [], items: new Collection([item(chapterA.id)]) },
{ chapters: [chapterA], items: new Collection([item(null)]) },
{ chapters: [chapterA], items: new Collection([item(chapterB.id)]) },
{ chapters: [chapterA], items: new Collection([item(chapterA.id), item(null, null)]) },
])("rejects ungrouped or unknown memberships", (structure) => {
expect(Quote.create(props(structure), quoteId).isFailure).toBe(true);
});
it("sums discounted lines per chapter before general discount and tax", () => {
const quote = Quote.create(props({
chapters: [chapterA, chapterB],
items: new Collection([
item(chapterA.id, 10000, 1000),
item(chapterA.id, 5000),
item(chapterA.id, null),
item(chapterB.id, 6000),
]),
}), quoteId).object;
expect(quote.getChapterSubtotal(chapterA.id).object.toPrimitive()).toBe(14000);
expect(quote.getChapterSubtotal(chapterB.id).object.toPrimitive()).toBe(6000);
expect(quote.subtotalPrice.toPrimitive()).toBe(20000);
expect(quote.totalPrice.toPrimitive()).toBe(21780);
expect(quote.getChapterSubtotal(UniqueID.generateNewID().object).isFailure).toBe(true);
});
it("returns zero in the quote currency for empty and text-only chapters", () => {
const quote = Quote.create(props({
currency: CurrencyData.createFromCode("USD").object,
chapters: [chapterA, chapterB],
items: new Collection([item(chapterA.id, null, 0, "USD")]),
}), quoteId).object;
for (const chapter of quote.chapters) {
expect(quote.getChapterSubtotal(chapter.id).object.toObject()).toEqual({
amount: 0, scale: 2, currency_code: "USD",
});
}
expect(quote.subtotalPrice.toObject()).toEqual({ amount: 0, scale: 2, currency_code: "USD" });
});
it("preserves totals when regrouping, reordering or changing PDF visibility", () => {
const structures: Partial<IQuoteProps>[] = [
{ items: new Collection([item(null, 9000), item(null, 5000)]) },
{ chapters: [chapterA, chapterB], items: new Collection([item(chapterA.id, 9000), item(chapterB.id, 5000)]) },
{ chapters: [chapterB, chapterA], items: new Collection([item(chapterB.id, 5000), item(chapterA.id, 9000)]), showChapterLineAmounts: false },
{ chapters: [chapterA, chapterB], items: new Collection([item(chapterB.id, 9000), item(chapterB.id, 5000)]) },
];
const quotes = structures.map((structure) => Quote.create(props(structure), quoteId).object);
expect(new Set(quotes.map((quote) => quote.totalPrice.toPrimitive())).size).toBe(1);
expect(quotes[2].showChapterLineAmounts).toBe(false);
});
});

View File

@ -0,0 +1,34 @@
import {
Description,
DomainError,
Entity,
IDomainError,
Result,
UniqueID,
} from "@shared/contexts";
export interface IQuoteChapterProps {
quoteId: UniqueID;
description: Description;
}
export class QuoteChapter extends Entity<IQuoteChapterProps> {
public static create(props: IQuoteChapterProps, id?: UniqueID): Result<QuoteChapter, IDomainError> {
if (!props.description.toString().trim()) {
return Result.fail(DomainError.create(
DomainError.INVALID_INPUT_DATA,
"Chapter description must not be empty",
{ path: "chapters.description" }
));
}
return Result.ok(new QuoteChapter(props, id));
}
get quoteId(): UniqueID {
return this.props.quoteId;
}
get description(): Description {
return this.props.description;
}
}

View File

@ -12,6 +12,7 @@ import {
} from "@shared/contexts";
export interface IQuoteItemProps extends IEntityProps {
chapterId?: UniqueID | null;
idArticle: ArticleIdentifier;
description: Description; // Descripción del artículo o servicio
quantity: Quantity; // Cantidad de unidades
@ -22,6 +23,7 @@ export interface IQuoteItemProps extends IEntityProps {
}
export interface IQuoteItem {
chapterId: UniqueID | null;
idArticle: ArticleIdentifier;
description: Description;
quantity: Quantity;
@ -36,6 +38,10 @@ export class QuoteItem extends Entity<IQuoteItemProps> implements IQuoteItem {
return Result.ok(new QuoteItem(props, id));
}
get chapterId(): UniqueID | null {
return this.props.chapterId ?? null;
}
get idArticle(): ArticleIdentifier {
return this.props.idArticle;
}

View File

@ -1,4 +1,5 @@
export * from "./Quote";
export * from "./QuoteChapter";
export * from "./QuoteCustomer";
export * from "./QuoteCustomerReference";
export * from "./QuoteItem";

View File

@ -10,7 +10,7 @@ export interface IQuoteRepository extends IRepository<Quote> {
create(quote: Quote): Promise<void>;
update(quote: Quote): Promise<void>;
getById(id: UniqueID): Promise<Quote | null>;
getById(id: UniqueID, options?: { forUpdate?: boolean }): Promise<Quote | null>;
findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Quote>>;
removeById(id: UniqueID): Promise<void>;

View File

@ -0,0 +1,125 @@
import { Sequelize, Transaction } from "sequelize";
import { ISequelizeAdapter } from "@/contexts/common/infrastructure/sequelize";
import { SequelizeBusinessTransaction } from "@/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction";
import { QuoteRepository } from "./Quote.repository";
import { mapper, rawQuote, record } from "./__fixtures__/quoteChapters.mock";
import { QuoteCreationAttributes, Quote_Model } from "./sequelize/quote.model";
// A transactional model double verifies repository ordering and transaction
// propagation. It does not replace an integration test against MariaDB.
const quote = mapper.mapToDomain(rawQuote());
type State = { header: Partial<QuoteCreationAttributes>; chapters: QuoteCreationAttributes["chapters"]; items: QuoteCreationAttributes["items"] };
type TestTransaction = Transaction & { draft: State };
const initialState = (): State => {
const { chapters, items, ...header } = mapper.mapToPersistence(quote);
return { header, chapters, items };
};
let state: State;
let events: string[];
let failAt: string | undefined;
const write = (name: string, options: { transaction: TestTransaction }, mutate: (draft: State) => void) => {
if (!options.transaction) throw new Error("Missing transaction");
events.push(name);
mutate(options.transaction.draft);
if (failAt === name) throw new Error("Injected write failure");
};
const headerModel = {
count: jest.fn(async ({ transaction }: { transaction: TestTransaction }) => transaction.draft.header.id ? 1 : 0),
update: jest.fn(async (data, options) => write("header", options, (draft) => { draft.header = { ...data, id: quote.id.toString() }; })),
create: jest.fn(async (data, options) => write("header", options, (draft) => { draft.header = data; })),
findByPk: jest.fn(async (_id, options) => {
const data = options.transaction?.draft ?? state;
return data.header.id ? { ...record(data.header), chapters: data.chapters.map(record), items: data.items.map(record) } : null;
}),
findOne: jest.fn(),
};
const chapterModel = {
destroy: jest.fn(async (options) => write("delete-chapters", options, (draft) => {
if (draft.items.length) throw new Error("Details must be deleted first");
draft.chapters = [];
})),
bulkCreate: jest.fn(async (data, options) => write("insert-chapters", options, (draft) => { draft.chapters = data; })),
};
const itemModel = {
destroy: jest.fn(async (options) => write("delete-items", options, (draft) => { draft.items = []; })),
bulkCreate: jest.fn(async (data: State["items"], options) => write("insert-items", options, (draft) => {
if (data.some((item) => item.chapter_id && !draft.chapters.some((chapter) => chapter.chapter_id === item.chapter_id))) throw new Error("Missing chapter");
draft.items = data;
})),
};
const models = { Quote_Model: headerModel, QuoteChapter_Model: chapterModel, QuoteItem_Model: itemModel };
const adapter = { getModel: (name: keyof typeof models) => models[name] } as unknown as ISequelizeAdapter;
const connection = {
transaction: async <T>(work: (t: TestTransaction) => Promise<T>) => {
const t = { draft: structuredClone(state) } as TestTransaction;
const result = await work(t);
state = t.draft;
return result;
},
} as unknown as Sequelize;
const complete = (work: (repo: QuoteRepository) => Promise<void>) =>
new SequelizeBusinessTransaction(connection).complete(async (transaction) => {
await work(new QuoteRepository({ mapper, adapter, transaction }));
});
beforeEach(() => {
jest.clearAllMocks();
state = initialState();
events = [];
failAt = undefined;
});
it("replaces details and chapters in dependency order and reloads them", async () => {
await complete(async (repo) => {
await repo.update(quote);
const loaded = await repo.getById(quote.id, { forUpdate: true });
expect(loaded?.chapters).toHaveLength(2);
expect(loaded?.items.totalCount).toBe(3);
expect(loaded?.totalPrice.toPrimitive()).toBe(quote.totalPrice.toPrimitive());
});
expect(events).toEqual(["header", "delete-items", "delete-chapters", "insert-chapters", "insert-items"]);
const options = headerModel.findByPk.mock.calls[0][1];
expect(options.lock).toBe(Transaction.LOCK.UPDATE);
expect(options.include.map((include: { as: string; separate: boolean; order: string[][] }) => [include.as, include.separate, include.order])).toEqual([
["chapters", true, [["position", "ASC"]]], ["items", true, [["position", "ASC"]]],
]);
});
it("creates header then chapters then details without nested automatic writes", async () => {
state = { header: {}, chapters: [], items: [] };
await complete((repo) => repo.create(quote));
expect(events).toEqual(["header", "insert-chapters", "insert-items"]);
expect(headerModel.create.mock.calls[0][0]).not.toHaveProperty("chapters");
expect(headerModel.create.mock.calls[0][1].include).toEqual([]);
});
it.each(["insert-chapters", "insert-items"])("propagates %s failure so the transaction rolls back", async (stage) => {
const snapshot = structuredClone(state);
failAt = stage;
await expect(complete((repo) => repo.update(quote))).rejects.toThrow("Injected write failure");
expect(state).toEqual(snapshot);
});
it("supports explicit ungrouping and free quotes through the same write path", async () => {
await complete((repo) => repo.update(mapper.mapToDomain(rawQuote(false))));
expect(state.chapters).toEqual([]);
expect(state.items.every((item) => item.chapter_id === null)).toBe(true);
expect(chapterModel.bulkCreate).not.toHaveBeenCalled();
});
it("returns null for missing quotes and rejects writes without a transaction", async () => {
state = { header: {}, chapters: [], items: [] };
const repo = new QuoteRepository({ mapper, adapter, transaction: null as unknown as Transaction });
expect(await repo.getById(quote.id)).toBeNull();
await expect(repo.update(quote)).rejects.toThrow("require a transaction");
expect(events).toEqual([]);
});
it("loads the last quote with chapters in the repository transaction", async () => {
headerModel.findOne.mockResolvedValue(rawQuote() as Quote_Model);
await complete(async (repo) => {
expect((await repo.findLastQuoteByDealerId(quote.dealerId))?.chapters).toHaveLength(2);
});
expect(headerModel.findOne.mock.calls[0][0].include.map((include: { as: string }) => include.as)).toEqual(["chapters", "items"]);
expect(headerModel.findOne.mock.calls[0][0].transaction).toBeDefined();
});

View File

@ -1,11 +1,12 @@
import { ISequelizeAdapter, SequelizeRepository } from "@/contexts/common/infrastructure/sequelize";
import { ICollection, IQueryCriteria, UniqueID, UTCDateValue } from "@shared/contexts";
import { ModelDefined, Transaction } from "sequelize";
import { IncludeOptions, ModelStatic, Transaction } from "sequelize";
import { IQuoteRepository } from "../domain";
import { Quote, QuoteReference, QuoteStatus } from "../domain/entities";
import { ISalesContext } from "./Sales.context";
import { createQuoteMapper, IQuoteMapper } from "./mappers/quote.mapper";
import { Quote_Model as QuoteModel } from "./sequelize/quote.model";
export type QueryParams = {
pagination: Record<string, any>;
@ -34,66 +35,22 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
}
public async create(quote: Quote): Promise<void> {
const quoteData = this.mapper.mapToPersistence(quote);
await this._save("Quote_Model", quote.id, quoteData);
await this._persistQuote(quote, false);
}
public async update(quote: Quote): Promise<void> {
console.time("update");
const quoteData = this.mapper.mapToPersistence(quote);
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
await Promise.all([
this._save("Quote_Model", quote.id, quoteData, {}),
QuoteItem_Model.destroy({
where: {
quote_id: quoteData.id,
},
transaction: this._transaction,
force: true,
}),
]);
await QuoteItem_Model.bulkCreate(quoteData.items, {
transaction: this._transaction,
});
console.timeEnd("update");
await this._persistQuote(quote, true);
}
public async getById(id: UniqueID): Promise<Quote | null> {
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
const rawQuote: any = await this._getById("Quote_Model", id, {
include: [
{
model: QuoteItem_Model,
as: "items",
},
],
order: [
[
{
model: QuoteItem_Model,
as: "items",
},
"position",
"asc",
],
],
public async getById(id: UniqueID, options: { forUpdate?: boolean } = {}): Promise<Quote | null> {
const rawQuote = await this._getById("Quote_Model", id, {
include: this._detailIncludes(),
lock: options.forUpdate ? Transaction.LOCK.UPDATE : undefined,
});
if (!rawQuote === true) {
return null;
}
return this.mapper.mapToDomain(rawQuote);
return rawQuote ? this.mapper.mapToDomain(rawQuote as unknown as QuoteModel) : null;
}
public async findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<Quote>> {
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
const { rows, count } = await this._findAll("Quote_Model", queryCriteria, {
include: [], // esto es para quitar las asociaciones al hacer la consulta
/*include: [
@ -118,25 +75,14 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
}
public async findLastQuoteByDealerId(dealerId: UniqueID): Promise<Quote | null> {
const Quote_Model: ModelDefined<any, any> = this._adapter.getModel("Quote_Model");
const QuoteItem_Model: ModelDefined<any, any> = this._adapter.getModel("QuoteItem_Model");
const rawQuote: any = await Quote_Model.findOne({
include: [
{
model: QuoteItem_Model,
as: "items",
},
],
const Quote_Model = this._adapter.getModel("Quote_Model") as ModelStatic<QuoteModel>;
const rawQuote = await Quote_Model.findOne({
include: this._detailIncludes(),
where: { dealer_id: dealerId.toString() },
order: [["created_at", "DESC"]], // Ordenamos por referencia en orden descendente
order: [["created_at", "DESC"]],
transaction: this._transaction,
});
if (!rawQuote === true) {
return null;
}
return this.mapper.mapToDomain(rawQuote);
return rawQuote ? this.mapper.mapToDomain(rawQuote) : null;
}
public async findLastReferenceByDealerId(dealerId: UniqueID): Promise<QuoteReference | null> {
@ -173,6 +119,29 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
}
);
}
private _detailIncludes(): IncludeOptions[] {
return [
{ model: this._adapter.getModel("QuoteChapter_Model"), as: "chapters", separate: true, order: [["position", "ASC"]] },
{ model: this._adapter.getModel("QuoteItem_Model"), as: "items", separate: true, order: [["position", "ASC"]] },
];
}
private async _persistQuote(quote: Quote, replace: boolean): Promise<void> {
if (!this._transaction) throw new Error("Quote writes require a transaction");
const { items, chapters, ...header } = this.mapper.mapToPersistence(quote);
const itemModel = this._adapter.getModel("QuoteItem_Model");
const chapterModel = this._adapter.getModel("QuoteChapter_Model");
const options = { transaction: this._transaction };
await this._save("Quote_Model", quote.id, header, { include: [] });
if (replace) {
await itemModel.destroy({ ...options, where: { quote_id: header.id }, force: true });
await chapterModel.destroy({ ...options, where: { quote_id: header.id }, force: true });
}
if (chapters.length) await chapterModel.bulkCreate(chapters, options);
if (items.length) await itemModel.bulkCreate(items, options);
}
}
export const registerQuoteRepository = (context: ISalesContext) => {

View File

@ -0,0 +1,69 @@
import { ICreateQuote_Request_DTO, IUpdateQuote_Request_DTO } from "@shared/contexts";
import { ISalesContext } from "../Sales.context";
import { createQuoteMapper } from "../mappers/quote.mapper";
import { createDealerMapper } from "../mappers/dealer.mapper";
import { Quote_Model } from "../sequelize/quote.model";
import { Dealer_Model } from "../sequelize/dealer.model";
export const quoteId = "35ea9365-f3d3-4127-ab31-7be82dcdd006";
export const dealerId = "2b15f09e-1eaa-46eb-8a8b-673eab8e3dda";
export const chapterA = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
export const chapterB = "46e95fed-e71d-4513-9d42-8b252aa5aa5a";
export const context = {} as ISalesContext;
export const mapper = createQuoteMapper(context);
export const record = <T extends object>(values: T) => ({ ...values, dataValues: values });
export const chapterRecords = [
{ quote_id: quoteId, chapter_id: chapterA, position: 0, description: "Armario" },
{ quote_id: quoteId, chapter_id: chapterB, position: 1, description: "Interior" },
];
export const itemRecords = [
{ quote_id: quoteId, item_id: "64b39469-5394-47a4-a8bd-70c8f4470a6e", chapter_id: chapterA, position: 0, id_article: null, description: "Panel", quantity: 100, unit_price: 10000, discount: 1000 },
{ quote_id: quoteId, item_id: "f951dfde-f620-4e8e-afb7-22aa3d416621", chapter_id: chapterB, position: 1, id_article: null, description: "Interior", quantity: 100, unit_price: 6000, discount: 0 },
{ quote_id: quoteId, item_id: "504d428f-6245-4f95-8e98-40a9bd69234c", chapter_id: chapterB, position: 2, id_article: null, description: "Texto libre", quantity: null, unit_price: null, discount: null },
];
export const headerRecord = {
id: quoteId, dealer_id: dealerId, status: "draft", date: "2026-10-19",
reference: "UE-0001", currency_code: "EUR", lang_code: "es",
customer_reference: "Customer", customer_information: "Customer information",
validity: "", payment_method: "", notes: "", discount: 1000, tax: 2100,
tax_label: "% IVA", date_sent: null, show_chapter_line_amounts: false,
};
export const rawQuote = (grouped = true) => ({
...record(headerRecord),
chapters: grouped ? chapterRecords.map(record) : [],
items: itemRecords.map((item) => record({ ...item, chapter_id: grouped ? item.chapter_id : null })),
}) as unknown as Quote_Model;
export const dealer = createDealerMapper(context).mapToDomain(record({
id: dealerId, user_id: chapterB, name: "Dealer", status: "actived",
lang_code: "es", currency_code: "EUR", logo: "", contact_information: "",
default_payment_method: "", default_notes: "", default_legal_terms: "",
default_quote_validity: "", default_show_line_discount_column: true,
default_tax: 2100, default_tax_label: "% IVA",
}) as unknown as Dealer_Model);
export const updateDTO = (grouped = true): IUpdateQuote_Request_DTO => ({
status: "draft", date: "2026-10-19", reference: "UE-0001",
customer_reference: "Customer", customer_information: "Customer information",
lang_code: "es", currency_code: "EUR", payment_method: "", notes: "", validity: "",
dealer_id: dealerId,
chapters: grouped ? chapterRecords.map((chapter) => ({ id: chapter.chapter_id, description: chapter.description })) : [],
show_chapter_line_amounts: false,
subtotal_price: { amount: 0, scale: 2, currency_code: "EUR" },
total_price: { amount: 0, scale: 2, currency_code: "EUR" },
discount: { amount: 1000, scale: 2 }, tax: { amount: 2100, scale: 2 },
items: itemRecords.map((item) => ({
id_article: "", description: item.description, chapter_id: grouped ? item.chapter_id : null,
quantity: { amount: item.quantity, scale: 2 },
unit_price: { amount: item.unit_price, scale: 2, currency_code: "EUR" },
subtotal_price: { amount: 0, scale: 2, currency_code: "EUR" },
total_price: { amount: 0, scale: 2, currency_code: "EUR" },
discount: { amount: item.discount, scale: 2 },
})),
});
export const createDTO = (grouped = true): ICreateQuote_Request_DTO => {
const dto = updateDTO(grouped);
return { ...dto, id: quoteId, items: dto.items.map((item) => ({ ...item, price: item.subtotal_price })) };
};

View File

@ -0,0 +1,94 @@
import {
Collection,
Description,
Language,
MoneyValue,
Percentage,
Quantity,
} from "@shared/contexts";
import { ArticleIdentifier } from "@/contexts/catalog/domain";
import { Quote, QuoteChapter, QuoteItem } from "@/contexts/sales/domain";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
import { Quote_Model } from "@/contexts/sales/infrastructure/sequelize/quote.model";
import { CreateQuotePresenter } from "./createQuote/presenter/CreateQuote.presenter";
import { DuplicateQuotePresenter } from "./duplicateQuote/presenter/DuplicateQuote.presenter";
import { GetQuotePresenter } from "./getQuote/presenter/GetQuote.presenter";
import { UpdateQuotePresenter } from "./updateQuote/presenter/UpdateQuote.presenter";
const context = {} as ISalesContext;
const legacy = createQuoteMapper(context).mapToDomain({
items: [],
dataValues: {
id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
status: "draft",
date: "2026-10-19",
reference: "Q-1",
currency_code: "EUR",
lang_code: "es",
customer_reference: "",
customer_information: "Customer",
validity: "",
payment_method: "",
notes: "",
discount: 0,
tax: 2100,
dealer_id: "770672b9-5a87-4e8e-a8af-8b44a7432e12",
date_sent: null,
},
} as unknown as Quote_Model);
const chapter = QuoteChapter.create({ quoteId: legacy.id, description: Description.create("Armario").object }).object;
const detail = QuoteItem.create({
chapterId: chapter.id,
idArticle: ArticleIdentifier.create().object,
description: Description.create("Detalle").object,
quantity: Quantity.create({ amount: 100, scale: 2 }).object,
unitPrice: MoneyValue.create({ amount: 10000, scale: 2 }).object,
discount: Percentage.create({ amount: 1000, scale: 2 }).object,
}).object;
const grouped = Quote.create({
status: legacy.status,
date: legacy.date,
reference: legacy.reference,
customerReference: legacy.customerReference,
customer: legacy.customer,
language: legacy.language,
currency: legacy.currency,
paymentMethod: legacy.paymentMethod,
notes: legacy.notes,
validity: legacy.validity,
discount: legacy.discount,
tax: legacy.tax,
taxLabel: legacy.taxLabel,
dealerId: legacy.dealerId,
dateSent: legacy.dateSent,
chapters: [chapter],
items: new Collection([detail]),
showChapterLineAmounts: false,
}, legacy.id).object;
describe.each([
{ name: "create", present: (quote: Quote) => CreateQuotePresenter.map(quote, context) },
{ name: "get", present: (quote: Quote) => GetQuotePresenter.map(quote, context, Language.createDefaultCode().object) },
{ name: "update", present: (quote: Quote) => UpdateQuotePresenter.map(quote, context) },
{ name: "duplicate", present: (quote: Quote) => DuplicateQuotePresenter.map(quote, context) },
])("$name chapter response contract", ({ present }) => {
it("includes chapters, calculated subtotal, membership and hidden amounts preference", async () => {
const dto = await present(grouped);
expect(dto.chapters).toEqual([{
id: chapter.id.toString(),
description: "Armario",
subtotal_price: { amount: 9000, scale: 2, currency_code: "EUR" },
}]);
expect(dto.items[0].chapter_id).toBe(chapter.id.toString());
expect(dto.show_chapter_line_amounts).toBe(false);
});
it("provides compatible defaults for historical quotes", async () => {
const dto = await present(legacy);
expect(dto.chapters).toEqual([]);
expect(dto.show_chapter_line_amounts).toBe(true);
});
});

View File

@ -27,6 +27,12 @@ export const CreateQuotePresenter: ICreateQuotePresenter = {
validity: quote.validity.toString(),
notes: quote.notes.toString(),
show_line_discount_column: quote.showLineDiscountColumn,
show_chapter_line_amounts: quote.showChapterLineAmounts,
chapters: quote.chapters.map((chapter) => ({
id: chapter.id.toString(),
description: chapter.description.toString(),
subtotal_price: quote.getChapterSubtotal(chapter.id).object.convertScale(2).toObject(),
})),
additional_currency_code: quote.additionalCurrency?.code ?? null,
additional_currency_rate: quote.additionalCurrencyRate,
additional_currency_date: quote.additionalCurrencyDate?.toISO8601() ?? null,
@ -59,6 +65,7 @@ const quoteItemPresenter = (
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
id_article: item.idArticle.toString(),
chapter_id: item.chapterId?.toString() ?? null,
description: item.description.toString(),
quantity: item.quantity.convertScale(2).toObject(),
unit_price: item.unitPrice.convertScale(2).toObject(),

View File

@ -27,6 +27,12 @@ export const DuplicateQuotePresenter: IDuplicateQuotePresenter = {
validity: quote.validity.toString(),
notes: quote.notes.toString(),
show_line_discount_column: quote.showLineDiscountColumn,
show_chapter_line_amounts: quote.showChapterLineAmounts,
chapters: quote.chapters.map((chapter) => ({
id: chapter.id.toString(),
description: chapter.description.toString(),
subtotal_price: quote.getChapterSubtotal(chapter.id).object.convertScale(2).toObject(),
})),
additional_currency_code: quote.additionalCurrency?.code ?? null,
additional_currency_rate: quote.additionalCurrencyRate,
additional_currency_date: quote.additionalCurrencyDate?.toISO8601() ?? null,
@ -59,6 +65,7 @@ const quoteItemPresenter = (
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
id_article: item.idArticle.toString(),
chapter_id: item.chapterId?.toString() ?? null,
description: item.description.toString(),
quantity: item.quantity.convertScale(2).toObject(),
unit_price: item.unitPrice.convertScale(2).toObject(),

View File

@ -34,6 +34,12 @@ export const GetQuotePresenter: IGetQuotePresenter = {
validity: quote.validity.toString(),
notes: quote.notes.toString(),
show_line_discount_column: quote.showLineDiscountColumn,
show_chapter_line_amounts: quote.showChapterLineAmounts,
chapters: quote.chapters.map((chapter) => ({
id: chapter.id.toString(),
description: chapter.description.toString(),
subtotal_price: quote.getChapterSubtotal(chapter.id).object.convertScale(2).toObject(),
})),
additional_currency_code: quote.additionalCurrency?.code ?? null,
additional_currency_rate: quote.additionalCurrencyRate,
additional_currency_date: quote.additionalCurrencyDate?.toISO8601() ?? null,
@ -69,6 +75,7 @@ const quoteItemPresenter = (
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
id_article: item.idArticle.toString(),
chapter_id: item.chapterId?.toString() ?? null,
description: item.description.toString(),
localized_description: item.idArticle.isNull()
? item.description.toString()

View File

@ -0,0 +1,135 @@
import { Result } from "@shared/contexts";
import { rawQuote } from "@/contexts/sales/infrastructure/__fixtures__/quoteChapters.mock";
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { loadReportLabels } from "./ReportQuote.i18n";
import { ReportQuotePresenter } from "./ReportQuote.reporter";
const context = {} as ISalesContext;
const mapper = createQuoteMapper(context);
const section = (html: string, id: string) => html.split(`<section id="${id}"`)[1].split("</section>")[0];
const detailRows = (html: string) => html.match(/<tr class="detail-row[\s\S]*?<\/tr>/g) ?? [];
const columnCount = (row: string) => (row.match(/<td\b/g) ?? []).length;
const configure = (grouped: boolean, amounts: boolean, discounts: boolean, currency = "EUR", language = "es") => {
const source = rawQuote(grouped);
Object.assign(source, { show_chapter_line_amounts: amounts, show_line_discount_column: discounts, currency_code: currency });
Object.assign(source.dataValues, { currency_code: currency, lang_code: language });
return source;
};
describe("Quote report chapters", () => {
it.each([true, false])("keeps legacy amounts without chapters even if preference is false (discounts=%s)", async (discounts) => {
const quote = mapper.mapToDomain(configure(false, false, discounts));
const html = await ReportQuotePresenter.toHTML(quote, context);
const details = section(html, "details");
expect(details).not.toContain('class="chapter-header"');
expect(details).not.toContain('class="chapter-footer"');
expect(details).toContain(quote.items.toArray()[0].unitPrice.toFormat());
expect(detailRows(details).map(columnCount)).toEqual(Array(3).fill(discounts ? 6 : 5));
});
it.each([true, false])("shows ordered chapter details and discounted subtotals (discounts=%s)", async (discounts) => {
const quote = mapper.mapToDomain(configure(true, true, discounts));
const html = await ReportQuotePresenter.toHTML(quote, context);
const details = section(html, "details");
expect(details.indexOf("1. Armario")).toBeLessThan(details.indexOf("Panel"));
expect(details.indexOf("Panel")).toBeLessThan(details.indexOf("Subtotal capítulo 1. Armario"));
expect(details.indexOf("Subtotal capítulo 1. Armario")).toBeLessThan(details.indexOf("2. Interior"));
for (const chapter of quote.chapters) {
expect(details).toContain(quote.getChapterSubtotal(chapter.id).object.toFormat());
}
expect(detailRows(details).map(columnCount)).toEqual(Array(3).fill(discounts ? 6 : 5));
expect(details.includes("Dto (%)")).toBe(discounts);
});
it.each([true, false])("removes all individual monetary columns and retains quantities, text and summary (discounts=%s)", async (discounts) => {
const source = configure(true, false, discounts);
const quote = mapper.mapToDomain(source);
const hidden = await ReportQuotePresenter.toHTML(quote, context);
const details = section(hidden, "details");
const rows = detailRows(details);
expect(rows.map(columnCount)).toEqual([2, 2, 2]);
expect(details).not.toContain("Prec. Unitario");
expect(details).not.toContain("Dto (%)");
expect(details).not.toContain("Importe total");
for (const [index, item] of quote.items.toArray().entries()) {
expect(rows[index]).toContain(item.description.toString());
expect(rows[index]).toContain(item.quantity.toFormat());
}
const pricedItems = [...quote.items.toArray().entries()].filter(([, item]) => !item.totalPrice.isNull());
for (const [index, item] of pricedItems) {
for (const value of [item.unitPrice, item.subtotalPrice, item.totalPrice, item.discount]) {
expect(rows[index]).not.toContain(value.toFormat());
}
}
expect(details).toContain('colspan="2"');
source.show_chapter_line_amounts = true;
const visible = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source), context);
expect(section(hidden, "resume")).toEqual(section(visible, "resume"));
expect(section(hidden, "resume")).toContain(quote.totalPrice.toFormat());
expect(section(hidden, "resume")).toContain(quote.discount.toFormat());
expect(section(hidden, "resume").includes("Descuentos")).toBe(discounts);
});
it.each(["es", "en", "fr"] as const)("localizes chapter footers in %s and escapes user text", async (language) => {
const source = configure(true, true, true, "EUR", language);
source.chapters![0].dataValues.description = '<script>alert("chapter")</script> & título';
source.items![0].dataValues.description = '<img src=x onerror="alert(1)">';
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source), context);
expect(html).toContain(loadReportLabels(language).chapter_subtotal);
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("&lt;img");
expect(html).not.toContain('<img src=x onerror="alert(1)">');
expect(html).not.toContain('<script>alert("chapter")</script>');
});
it("prints empty and text-only chapters as zero, including non-EUR currencies and additional totals", async () => {
const source = configure(true, false, true, "USD");
source.items = source.items!.filter((item) => item.dataValues.description === "Texto libre");
const additional = { additional_currency_code: "EUR", additional_currency_rate: 0.9, additional_currency_date: "2026-10-19" };
Object.assign(source, additional);
Object.assign(source.dataValues, additional);
const quote = mapper.mapToDomain(source);
const html = await ReportQuotePresenter.toHTML(quote, context);
expect(detailRows(section(html, "details"))).toHaveLength(1);
const footers = html.match(/<tr class="chapter-footer">[\s\S]*?<\/tr>/g)!;
expect(footers).toHaveLength(2);
for (const [index, chapter] of quote.chapters.entries()) {
expect(footers[index]).toContain(quote.getChapterSubtotal(chapter.id).object.toFormat());
}
expect(html).toContain(quote.additionalCurrencyTotal!.toFormat());
expect(html).toContain((0.9).toLocaleString(undefined, { maximumFractionDigits: 8 }));
});
it.each([true, false])("renders priced USD lines together with text blocks (amounts=%s)", async (amounts) => {
const source = configure(true, amounts, true, "USD");
const additional = { additional_currency_code: "EUR", additional_currency_rate: 0.9, additional_currency_date: "2026-10-19" };
Object.assign(source, additional);
Object.assign(source.dataValues, additional);
const quote = mapper.mapToDomain(source);
const html = await ReportQuotePresenter.toHTML(quote, context);
const details = section(html, "details");
expect(detailRows(details)).toHaveLength(3);
expect(details).toContain(quote.getChapterSubtotal(quote.chapters[0].id).object.toFormat());
expect(section(html, "resume")).toContain(quote.totalPrice.toFormat());
expect(details).toContain("Texto libre");
expect(html).toContain(quote.additionalCurrencyTotal!.toFormat());
expect(html).toContain("1 USD =");
});
it("preserves catalog localization and persisted description fallback inside chapters", async () => {
const source = configure(true, false, false, "EUR", "fr");
const articleId = "12345";
Object.assign(source.items![0].dataValues, { id_article: articleId });
const execute = jest.fn().mockResolvedValue(Result.ok([{
idArticle: { toString: () => articleId },
getTranslation: () => ({ description: { toString: () => "Panneau traduit" } }),
}]));
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source), {
catalogArticles: { execute },
} as unknown as ISalesContext);
expect(execute).toHaveBeenCalledTimes(1);
expect(section(html, "details")).toContain("Panneau traduit");
expect(section(html, "details")).toContain("Texto libre");
});
});

View File

@ -20,6 +20,7 @@ export interface ReportLabels {
description: string;
unit_price: string;
subtotal: string;
chapter_subtotal: string;
discount: string;
total_amount: string;
payment_method: string;
@ -39,7 +40,7 @@ export const resolveReportLanguage = (language: string): ReportLanguage =>
const requiredKeys = [
"official_distributor", "dealer_logo", "document_title", "browser_title", "quote_number",
"date", "validity", "salesperson", "customer_reference", "page", "of", "quantity",
"description", "unit_price", "subtotal", "discount", "total_amount", "payment_method",
"description", "unit_price", "subtotal", "chapter_subtotal", "discount", "total_amount", "payment_method",
"notes", "net_amount", "discounts", "percentage_discount", "taxable_amount",
] satisfies (keyof ReportLabels)[];

View File

@ -1,3 +1,5 @@
import puppeteer from "puppeteer";
import report from "puppeteer-report";
import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
import { loadReportLabels, resolveReportLanguage } from "./ReportQuote.i18n";
@ -87,3 +89,36 @@ describe("ReportQuotePresenter", () => {
expect(loadReportLabels("fr").document_title).toBe("DEVIS");
});
});
describe("Quote PDF browser lifecycle", () => {
afterEach(() => jest.restoreAllMocks());
const setup = () => {
const page = { setContent: jest.fn().mockResolvedValue(undefined) };
const browser = { newPage: jest.fn().mockResolvedValue(page), close: jest.fn().mockResolvedValue(undefined) };
jest.spyOn(puppeteer, "launch").mockResolvedValue(browser as unknown as Awaited<ReturnType<typeof puppeteer.launch>>);
jest.spyOn(ReportQuotePresenter, "toHTML").mockResolvedValue("<html>Chapter report</html>");
const pdf = jest.spyOn(report, "pdfPage").mockResolvedValue(Buffer.from("pdf-content"));
const context = {} as ISalesContext;
const quote = createQuoteMapper(context).mapToDomain(source(false, 0));
return { page, browser, pdf, context, quote };
};
it("loads the report HTML directly, generates A4 and closes the browser", async () => {
const { page, browser, pdf, context, quote } = setup();
await expect(ReportQuotePresenter.toPDF(quote, context)).resolves.toEqual(Buffer.from("pdf-content"));
expect(page.setContent).toHaveBeenCalledWith("<html>Chapter report</html>", { waitUntil: "networkidle2" });
expect(pdf).toHaveBeenCalledWith(page, expect.objectContaining({ format: "A4" }));
expect(browser.close).toHaveBeenCalledTimes(1);
});
it.each(["load", "render"])("closes the browser and propagates a %s failure", async (stage) => {
const { page, browser, pdf, context, quote } = setup();
const error = new Error("PDF failed");
if (stage === "load") page.setContent.mockRejectedValue(error);
else pdf.mockRejectedValue(error);
await expect(ReportQuotePresenter.toPDF(quote, context)).rejects.toThrow(error);
expect(browser.close).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,4 +1,4 @@
import { ICollection, MoneyValue } from "@shared/contexts";
import { MoneyValue } from "@shared/contexts";
import { Quote, QuoteItem } from "../../../../../../domain";
import { ISalesContext } from "../../../../../Sales.context";
@ -44,23 +44,22 @@ export const ReportQuotePresenter: IReportQuoteReporter = {
],
});
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation();
await page.setContent(html, { waitUntil: "networkidle2" });
await navigationPromise;
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
bottom: "10mm",
left: "10mm",
right: "10mm",
top: "10mm",
},
});
await browser.close();
return Buffer.from(reportPDF);
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "networkidle2" });
const reportPDF = await report.pdfPage(page, {
format: "A4",
margin: {
bottom: "10mm",
left: "10mm",
right: "10mm",
top: "10mm",
},
});
return Buffer.from(reportPDF);
} finally {
await browser.close();
}
},
};
@ -74,6 +73,35 @@ const map = async (quote: Quote, context: ISalesContext) => {
context.catalogArticles
);
const showLineAmounts = quote.chapters.length === 0 || quote.showChapterLineAmounts;
const showDetailDiscountColumn = showLineAmounts && quote.showLineDiscountColumn;
const items = quote.items.toArray();
const detailRows = (details: readonly QuoteItem[]) => details.map((item) => ({
description: item.idArticle.isNull()
? item.description.toString()
: localizedDescriptions.get(item.idArticle.toString()) ?? item.description.toString(),
quantity: item.quantity.toFormat(),
// Hidden amounts are absent from the report projection as well as the template.
...(showLineAmounts ? {
unit_price: item.unitPrice.toFormat(),
subtotal_price: item.subtotalPrice.toFormat(),
discount: showDetailDiscountColumn ? item.discount.toFormat() : undefined,
total_price: item.totalPrice.toFormat(),
} : {}),
}));
const rows = quote.chapters.length
? quote.chapters.flatMap((chapter, index) => {
const subtotal = quote.getChapterSubtotal(chapter.id);
if (subtotal.isFailure) throw subtotal.error;
const title = `${index + 1}. ${chapter.description.toString()}`;
return [
{ chapter_header: true, title },
...detailRows(items.filter((item) => item.chapterId?.equals(chapter.id))),
{ chapter_footer: true, title, total_price: subtotal.object.toFormat() },
];
})
: detailRows(items);
return {
id: quote.id.toString(),
status: quote.status.toString(),
@ -83,17 +111,22 @@ const map = async (quote: Quote, context: ISalesContext) => {
customer_information: quote.customer.toString(),
lang_code: langCode,
labels: loadReportLabels(langCode),
currency_code: quote.currency.toString(),
currency_code: quote.currency.code,
payment_method: quote.paymentMethod.toString(),
validity: quote.validity.toString(),
notes: quote.notes.toString(),
show_line_discount_column: quote.showLineDiscountColumn,
show_line_amounts: showLineAmounts,
show_detail_discount_column: showDetailDiscountColumn,
detail_column_count: showLineAmounts ? (showDetailDiscountColumn ? 6 : 5) : 2,
has_general_discount: quote.discount.toNumber() > 0,
line_discount_price: quote.items
.toArray()
.reduce(
(total, item) => total.add(item.subtotalPrice.subtract(item.totalPrice)),
(total, item) => item.totalPrice.isNull()
? total
: total.add(item.subtotalPrice.subtract(item.totalPrice)),
MoneyValue.create({ amount: 0, scale: 2, currencyCode: quote.currency.code }).object
)
.toFormat(),
@ -123,7 +156,7 @@ const map = async (quote: Quote, context: ISalesContext) => {
}
: undefined,
items: quoteItemPresenter(quote.items, context, localizedDescriptions),
rows,
dealer: {
id: dealer?.id.toString(),
@ -139,24 +172,3 @@ const map = async (quote: Quote, context: ISalesContext) => {
},
};
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const quoteItemPresenter = (
items: ICollection<QuoteItem>,
context: ISalesContext,
localizedDescriptions: Map<string, string>
): any[] =>
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
id_article: item.idArticle.toString(),
description: item.idArticle.isNull()
? item.description.toString()
: localizedDescriptions.get(item.idArticle.toString()) ?? item.description.toString(),
quantity: item.quantity.toFormat(),
unit_price: item.unitPrice.toFormat(),
subtotal_price: item.subtotalPrice.toFormat(),
discount: item.discount.toFormat(),
total_price: item.totalPrice.toFormat(),
}))
: [];
2;

View File

@ -1,3 +1,3 @@
{
"official_distributor": "OFFICIAL DISTRIBUTOR", "dealer_logo": "Dealer logo", "document_title": "QUOTE", "browser_title": "Quote", "quote_number": "Quote no.:", "date": "Date:", "validity": "Validity:", "salesperson": "Salesperson:", "customer_reference": "Customer reference:", "page": "Page", "of": "of", "quantity": "Qty.", "description": "Description", "unit_price": "Unit price", "subtotal": "Subtotal", "discount": "Discount (%)", "total_amount": "Total amount", "payment_method": "Payment method:", "notes": "Notes:", "net_amount": "Net amount", "discounts": "Discounts", "percentage_discount": "% Discount", "taxable_amount": "Taxable amount", "additional_currency_total": "Total amount in", "exchange_rate": "Exchange rate:", "exchange_date": "Exchange date:"
"official_distributor": "OFFICIAL DISTRIBUTOR", "dealer_logo": "Dealer logo", "document_title": "QUOTE", "browser_title": "Quote", "quote_number": "Quote no.:", "date": "Date:", "validity": "Validity:", "salesperson": "Salesperson:", "customer_reference": "Customer reference:", "page": "Page", "of": "of", "quantity": "Qty.", "description": "Description", "unit_price": "Unit price", "subtotal": "Subtotal", "discount": "Discount (%)", "total_amount": "Total amount", "payment_method": "Payment method:", "notes": "Notes:", "net_amount": "Net amount", "discounts": "Discounts", "percentage_discount": "% Discount", "taxable_amount": "Taxable amount", "additional_currency_total": "Total amount in", "exchange_rate": "Exchange rate:", "exchange_date": "Exchange date:", "chapter_subtotal": "Chapter subtotal"
}

View File

@ -1,3 +1,3 @@
{
"official_distributor": "DISTRIBUIDOR OFICIAL", "dealer_logo": "Logo distribuidor", "document_title": "PRESUPUESTO", "browser_title": "Presupuesto", "quote_number": "Presupuesto nº:", "date": "Fecha:", "validity": "Validez:", "salesperson": "Vendedor:", "customer_reference": "Referencia cliente:", "page": "Página", "of": "de", "quantity": "Cant.", "description": "Descripción", "unit_price": "Prec. Unitario", "subtotal": "Subtotal", "discount": "Dto (%)", "total_amount": "Importe total", "payment_method": "Forma de pago:", "notes": "Notas:", "net_amount": "Importe neto", "discounts": "Descuentos", "percentage_discount": "% Descuento", "taxable_amount": "Base imponible", "additional_currency_total": "Importe total en", "exchange_rate": "Tipo de cambio:", "exchange_date": "Fecha de cambio:"
"official_distributor": "DISTRIBUIDOR OFICIAL", "dealer_logo": "Logo distribuidor", "document_title": "PRESUPUESTO", "browser_title": "Presupuesto", "quote_number": "Presupuesto nº:", "date": "Fecha:", "validity": "Validez:", "salesperson": "Vendedor:", "customer_reference": "Referencia cliente:", "page": "Página", "of": "de", "quantity": "Cant.", "description": "Descripción", "unit_price": "Prec. Unitario", "subtotal": "Subtotal", "discount": "Dto (%)", "total_amount": "Importe total", "payment_method": "Forma de pago:", "notes": "Notas:", "net_amount": "Importe neto", "discounts": "Descuentos", "percentage_discount": "% Descuento", "taxable_amount": "Base imponible", "additional_currency_total": "Importe total en", "exchange_rate": "Tipo de cambio:", "exchange_date": "Fecha de cambio:", "chapter_subtotal": "Subtotal capítulo"
}

View File

@ -1,3 +1,3 @@
{
"official_distributor": "DISTRIBUTEUR OFFICIEL", "dealer_logo": "Logo du distributeur", "document_title": "DEVIS", "browser_title": "Devis", "quote_number": "Devis n° :", "date": "Date :", "validity": "Validité :", "salesperson": "Commercial :", "customer_reference": "Référence client :", "page": "Page", "of": "sur", "quantity": "Qté", "description": "Description", "unit_price": "Prix unitaire", "subtotal": "Sous-total", "discount": "Remise (%)", "total_amount": "Montant total", "payment_method": "Mode de paiement :", "notes": "Notes :", "net_amount": "Montant net", "discounts": "Remises", "percentage_discount": "% de remise", "taxable_amount": "Base imposable", "additional_currency_total": "Montant total en", "exchange_rate": "Taux de change :", "exchange_date": "Date de change :"
"official_distributor": "DISTRIBUTEUR OFFICIEL", "dealer_logo": "Logo du distributeur", "document_title": "DEVIS", "browser_title": "Devis", "quote_number": "Devis n° :", "date": "Date :", "validity": "Validité :", "salesperson": "Commercial :", "customer_reference": "Référence client :", "page": "Page", "of": "sur", "quantity": "Qté", "description": "Description", "unit_price": "Prix unitaire", "subtotal": "Sous-total", "discount": "Remise (%)", "total_amount": "Montant total", "payment_method": "Mode de paiement :", "notes": "Notes :", "net_amount": "Montant net", "discounts": "Remises", "percentage_discount": "% de remise", "taxable_amount": "Base imposable", "additional_currency_total": "Montant total en", "exchange_rate": "Taux de change :", "exchange_date": "Date de change :", "chapter_subtotal": "Sous-total du chapitre"
}

View File

@ -18,13 +18,29 @@
padding-bottom: 0;
}
#footer {}
#details table { width: 100%; border-collapse: collapse; }
#details th, #details td { padding: 8px; vertical-align: top; }
#details th { border: 1px solid #ddd; white-space: nowrap; }
#details th:first-child { width: 1%; }
#details td.text-right { white-space: nowrap; }
#details .detail-row { border-bottom: 1px solid #ddd; }
#details .detail-description { white-space: pre-wrap; overflow-wrap: anywhere; }
#details .chapter-header, #details .chapter-footer { background: #e8eff4; font-weight: bold; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
#details .chapter-summary { display: flex; justify-content: space-between; gap: 16px; }
#details .chapter-total { white-space: nowrap; }
#details .chapter-title { white-space: pre-wrap; overflow-wrap: anywhere; }
@media print {
thead {
display: table-header-group;
}
#resume { break-inside: avoid; }
#details tr { break-inside: avoid; }
#details .chapter-header { break-after: avoid; }
#details .chapter-footer { break-before: avoid; }
tfoot {
display: table-footer-group;
}
@ -75,26 +91,45 @@
<tr>
<th class="px-2 py-2 text-right border">{{labels.quantity}}</th>
<th class="px-2 py-2 border">{{labels.description}}</th>
{{#if show_line_amounts}}
<th class="px-2 py-2 text-right border">{{labels.unit_price}}</th>
<th class="px-2 py-2 text-right border">{{labels.subtotal}}</th>
{{#if show_line_discount_column}}
{{#if show_detail_discount_column}}
<th class="px-2 py-2 text-right border">{{labels.discount}}</th>
{{/if}}
<th class="px-2 py-2 text-right border">{{labels.total_amount}}</th>
{{/if}}
</tr>
</thead>
<tbody class="table-body">
{{#each items}}
<tr class="text-sm border-b">
{{#each rows}}
{{#if chapter_header}}
<tr class="chapter-header">
<td colspan="{{../detail_column_count}}" class="chapter-title">{{title}}</td>
</tr>
{{else if chapter_footer}}
<tr class="chapter-footer">
<td colspan="{{../detail_column_count}}">
<div class="chapter-summary">
<span class="chapter-title">{{../labels.chapter_subtotal}} {{title}}</span>
<span class="chapter-total">{{total_price}}</span>
</div>
</td>
</tr>
{{else}}
<tr class="detail-row text-sm border-b">
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
<td class="px-2 py-2 font-medium">{{description}}</td>
<td class="detail-description px-2 py-2 font-medium">{{description}}</td>
{{#if ../show_line_amounts}}
<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}}
{{#if ../show_detail_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>
{{/if}}
</tr>
{{/if}}
{{/each}}
</tbody>
</table>
@ -129,7 +164,7 @@
{{#if has_general_discount}}
<tr class="border-b">
<td class="px-4 py-2">{{labels.percentage_discount}}</td>
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
<td class="px-4 py-2 text-right border">{{discount}}</td>
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
</tr>
{{/if}}
@ -159,7 +194,7 @@
<section class="pt-4 mt-4 border-t">
<p><strong>{{labels.additional_currency_total}} {{additional_currency.name}} ({{additional_currency.code}}):
{{additional_currency.total}}</strong></p>
<p class="text-xs text-gray-500">{{labels.exchange_rate}} 1 EUR = {{additional_currency.rate}}
<p class="text-xs text-gray-500">{{labels.exchange_rate}} 1 {{currency_code}} = {{additional_currency.rate}}
{{additional_currency.code}} · {{labels.exchange_date}} {{additional_currency.date}}</p>
</section>
{{/if}}

View File

@ -27,6 +27,12 @@ export const UpdateQuotePresenter: IUpdateQuotePresenter = {
validity: quote.validity.toString(),
notes: quote.notes.toString(),
show_line_discount_column: quote.showLineDiscountColumn,
show_chapter_line_amounts: quote.showChapterLineAmounts,
chapters: quote.chapters.map((chapter) => ({
id: chapter.id.toString(),
description: chapter.description.toString(),
subtotal_price: quote.getChapterSubtotal(chapter.id).object.convertScale(2).toObject(),
})),
additional_currency_code: quote.additionalCurrency?.code ?? null,
additional_currency_rate: quote.additionalCurrencyRate,
additional_currency_date: quote.additionalCurrencyDate?.toISO8601() ?? null,
@ -59,6 +65,7 @@ const quoteItemPresenter = (
items.totalCount > 0
? items.items.map((item: QuoteItem) => ({
id_article: item.idArticle.toString(),
chapter_id: item.chapterId?.toString() ?? null,
description: item.description.toString(),
quantity: item.quantity.convertScale(2).toObject(),
unit_price: item.unitPrice.convertScale(2).toObject(),

View File

@ -19,6 +19,7 @@ import {
import { QuoteStatus } from "../../domain/entities/Quotes/QuoteStatus";
import { ISalesContext } from "../Sales.context";
import { DEFAULT_QUOTE_TAX_LABEL, QuoteCreationAttributes, Quote_Model } from "../sequelize";
import { createQuoteChapterMapper, IQuoteChapterMapper } from "./quoteChapter.mapper";
import { IQuoteItemMapper, createQuoteItemMapper } from "./quoteItem.mapper";
export interface IQuoteMapper
@ -28,24 +29,30 @@ export const createQuoteMapper = (context: ISalesContext): IQuoteMapper =>
new QuoteMapper({
context,
quoteItemMapper: createQuoteItemMapper(context),
quoteChapterMapper: createQuoteChapterMapper(),
});
class QuoteMapper
extends SequelizeMapper<Quote_Model, QuoteCreationAttributes, Quote>
implements IQuoteMapper
{
public constructor(props: { quoteItemMapper: IQuoteItemMapper; context: ISalesContext }) {
public constructor(props: { quoteChapterMapper: IQuoteChapterMapper; quoteItemMapper: IQuoteItemMapper; context: ISalesContext }) {
super(props);
}
protected toDomainMappingImpl(source: Quote_Model): Quote {
const id = this.mapsValue(source, "id", UniqueID.create);
const items = (this.props.quoteItemMapper as IQuoteItemMapper).mapArrayToDomain(source.items, {
const items = (this.props.quoteItemMapper as IQuoteItemMapper).mapArrayToDomain(source.items ? [...source.items].sort((a, b) => a.position - b.position) : [], {
sourceParent: source,
});
const chapters = (this.props.quoteChapterMapper as IQuoteChapterMapper)
.mapArrayToDomain([...(source.chapters ?? [])].sort((a, b) => a.position - b.position)).toArray();
const props: IQuoteProps = {
chapters,
showChapterLineAmounts: source.show_chapter_line_amounts ?? true,
status: this.mapsValue(source, "status", QuoteStatus.create),
date: this.mapsValue(source, "date", UTCDateValue.create),
reference: this.mapsValue(source, "reference", QuoteReference.create),
@ -129,7 +136,17 @@ class QuoteMapper
{ sourceParent: source }
);
const chapters = source.chapters.map((chapter, index) =>
(this.props.quoteChapterMapper as IQuoteChapterMapper).mapToPersistence(chapter, { index })
);
// Canonical flat order: chapters first, preserving the relative detail order.
const orderedItems = chapters.length
? chapters.flatMap((chapter) => items.filter((item) => item.chapter_id === chapter.chapter_id))
: items;
const quote: QuoteCreationAttributes = {
chapters,
show_chapter_line_amounts: source.showChapterLineAmounts,
id: source.id.toPrimitive(),
status: source.status.toPrimitive(),
date: source.date.toPrimitive(),
@ -160,7 +177,7 @@ class QuoteMapper
total_price: source.totalPrice.convertScale(2).toPrimitive(),
items,
items: orderedItems.map((item, position) => ({ ...item, position })),
dealer_id: source.dealerId.toPrimitive(),
date_sent: source.dateSent.toPrimitive() === "" ? null : source.dateSent.toPrimitive(),

View File

@ -0,0 +1,32 @@
import { ISequelizeMapper, SequelizeMapper } from "@/contexts/common/infrastructure";
import { Description, UniqueID } from "@shared/contexts";
import { QuoteChapter } from "../../domain";
import { QuoteChapterCreationAttributes, QuoteChapter_Model } from "../sequelize/quoteChapter.model";
export interface IQuoteChapterMapper extends ISequelizeMapper<
QuoteChapter_Model, QuoteChapterCreationAttributes, QuoteChapter
> {}
export const createQuoteChapterMapper = (): IQuoteChapterMapper => new QuoteChapterMapper({});
class QuoteChapterMapper extends SequelizeMapper<
QuoteChapter_Model, QuoteChapterCreationAttributes, QuoteChapter
> implements IQuoteChapterMapper {
protected toDomainMappingImpl(source: QuoteChapter_Model): QuoteChapter {
const result = QuoteChapter.create({
quoteId: this.mapsValue(source, "quote_id", UniqueID.create),
description: this.mapsValue(source, "description", Description.create),
}, this.mapsValue(source, "chapter_id", UniqueID.create));
if (result.isFailure) throw result.error;
return result.object;
}
protected toPersistenceMappingImpl(source: QuoteChapter, params: { index: number }): QuoteChapterCreationAttributes {
return {
quote_id: source.quoteId.toString(),
chapter_id: source.id.toString(),
description: source.description.toString(),
position: params.index,
};
}
}

View File

@ -0,0 +1,53 @@
import { UniqueID } from "@shared/contexts";
import { chapterA, chapterB, mapper, rawQuote, record, quoteId } from "../__fixtures__/quoteChapters.mock";
import { Quote_Model } from "../sequelize/quote.model";
it("round-trips chapters, text blocks, memberships, order and preference", () => {
const raw = rawQuote();
raw.chapters.reverse();
raw.items.reverse();
const domain = mapper.mapToDomain(raw);
expect(domain.chapters.map((chapter) => chapter.id.toString())).toEqual([chapterA, chapterB]);
expect(domain.items.toArray().map((item) => item.description.toString())).toEqual(["Panel", "Interior", "Texto libre"]);
const saved = mapper.mapToPersistence(domain);
expect(saved.chapters.map((chapter) => [chapter.chapter_id, chapter.position])).toEqual([[chapterA, 0], [chapterB, 1]]);
expect(saved.items.map((item) => item.chapter_id)).toEqual([chapterA, chapterB, chapterB]);
expect(saved.show_chapter_line_amounts).toBe(false);
const reloaded = mapper.mapToDomain({
...record(saved), chapters: saved.chapters.map(record), items: saved.items.map(record),
} as unknown as Quote_Model);
expect(reloaded.totalPrice.toPrimitive()).toBe(domain.totalPrice.toPrimitive());
expect(reloaded.items.toArray()[2].quantity.isNull()).toBe(true);
expect(reloaded.chapters.map((chapter) => chapter.quoteId.toString())).toEqual([quoteId, quoteId]);
});
it("serializes interleaved details in chapter order while preserving interior order", () => {
const raw = rawQuote();
raw.chapters[0].position = 1;
raw.chapters[1].position = 0;
const saved = mapper.mapToPersistence(mapper.mapToDomain(raw));
expect(saved.chapters.map((chapter) => chapter.chapter_id)).toEqual([chapterB, chapterA]);
expect(saved.items.map((item) => item.description)).toEqual(["Interior", "Texto libre", "Panel"]);
expect(saved.items.map((item) => item.position)).toEqual([0, 1, 2]);
});
it("maps historical absent associations and null visibility to legacy defaults", () => {
const raw = rawQuote(false);
delete (raw as Partial<Quote_Model>).chapters;
delete (raw as Partial<Quote_Model>).show_chapter_line_amounts;
const domain = mapper.mapToDomain(raw);
expect(domain.chapters).toEqual([]);
expect(domain.showChapterLineAmounts).toBe(true);
expect(mapper.mapToPersistence(domain).items.every((item) => item.chapter_id === null)).toBe(true);
});
it.each(["owner", "membership"])("rejects corrupt persisted chapter %s", (kind) => {
const raw = rawQuote();
if (kind === "owner") {
raw.chapters[0].dataValues.quote_id = UniqueID.generateNewID().object.toString();
} else {
raw.items[0].chapter_id = UniqueID.generateNewID().object.toString();
raw.items[0].dataValues.chapter_id = raw.items[0].chapter_id;
}
expect(() => mapper.mapToDomain(raw)).toThrow();
});

View File

@ -27,6 +27,9 @@ class QuoteItemMapper
const props: IQuoteItemProps = {
idArticle,
chapterId: source.chapter_id
? this.mapsValue(source, "chapter_id", UniqueID.create)
: null,
description: this.mapsValue(source, "description", Description.create),
quantity: this.mapsValue(source, "quantity", (quantity) =>
Quantity.create({
@ -84,6 +87,7 @@ class QuoteItemMapper
return {
item_id: source.id.toString(),
chapter_id: source.chapterId?.toString() ?? null,
quote_id: sourceParent.id.toPrimitive(),
position: index,
id_article: source.idArticle.toPrimitive(),

View File

@ -1,2 +1,3 @@
export * from "./dealer.model";
export * from "./quote.model";
export * from "./quoteChapter.model";

View File

@ -1,6 +1,7 @@
import {
CreationOptional,
DataTypes,
ForeignKey,
InferAttributes,
InferCreationAttributes,
Model,
@ -8,25 +9,27 @@ import {
Op,
Sequelize,
} from "sequelize";
import { QuoteChapterCreationAttributes, QuoteChapter_Model } from "./quoteChapter.model";
import { Dealer_Model } from "./dealer.model";
import { QuoteItemCreationAttributes, QuoteItem_Model } from "./quoteItem.model";
export type QuoteCreationAttributes = InferCreationAttributes<
Quote_Model,
{ omit: "items" | "dealer" | "id_contract" }
{ omit: "items" | "chapters" | "dealer" | "id_contract" }
> & {
items: QuoteItemCreationAttributes[];
chapters: QuoteChapterCreationAttributes[];
dealer_id: string;
};
export const DEFAULT_QUOTE_TAX_LABEL = "% IVA";
export class Quote_Model extends Model<
InferAttributes<Quote_Model, { omit: "items" | "dealer" }>,
InferCreationAttributes<Quote_Model, { omit: "items" | "dealer" }>
InferAttributes<Quote_Model, { omit: "items" | "chapters" | "dealer" }>,
InferCreationAttributes<Quote_Model, { omit: "items" | "chapters" | "dealer" }>
> {
static associate(connection: Sequelize) {
const { Quote_Model, QuoteItem_Model, Dealer_Model } = connection.models;
const { Quote_Model, QuoteItem_Model, QuoteChapter_Model, Dealer_Model } = connection.models;
Quote_Model.hasMany(QuoteItem_Model, {
as: "items",
@ -34,6 +37,12 @@ export class Quote_Model extends Model<
onDelete: "CASCADE",
});
Quote_Model.hasMany(QuoteChapter_Model, {
as: "chapters",
foreignKey: "quote_id",
onDelete: "CASCADE",
});
Quote_Model.belongsTo(Dealer_Model, {
foreignKey: "dealer_id",
as: "dealer",
@ -42,6 +51,7 @@ export class Quote_Model extends Model<
}
declare id: string;
declare dealer_id: ForeignKey<string>;
declare status: CreationOptional<string>;
declare date: CreationOptional<string>;
declare reference: CreationOptional<string>;
@ -52,6 +62,8 @@ export class Quote_Model extends Model<
declare payment_method: CreationOptional<string>;
declare notes: CreationOptional<string>;
declare validity: CreationOptional<string>;
declare show_chapter_line_amounts: CreationOptional<boolean>;
declare chapters: NonAttribute<QuoteChapter_Model[]>;
declare show_line_discount_column: CreationOptional<boolean>;
declare additional_currency_code: CreationOptional<string | null>;
declare additional_currency_rate: CreationOptional<number | null>;
@ -132,6 +144,11 @@ export default (sequelize: Sequelize) => {
type: DataTypes.TEXT,
},
show_chapter_line_amounts: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
show_line_discount_column: {
type: DataTypes.BOOLEAN,
allowNull: false,

View File

@ -0,0 +1,25 @@
import { DataTypes, Sequelize } from "sequelize";
import initQuote, { Quote_Model } from "./quote.model";
import initItems, { QuoteItem_Model } from "./quoteItem.model";
import initChapters, { QuoteChapter_Model } from "./quoteChapter.model";
it("registers chapter metadata, quote associations and legacy defaults without connecting", () => {
const connection = new Sequelize("metadata_test", "", "", { dialect: "mysql", logging: false });
connection.define("Dealer_Model", { id: { type: DataTypes.UUID, primaryKey: true } });
initQuote(connection);
initItems(connection);
initChapters(connection);
Quote_Model.associate(connection);
QuoteItem_Model.associate(connection);
QuoteChapter_Model.associate(connection);
expect(QuoteChapter_Model.primaryKeyAttributes).toEqual(["quote_id", "chapter_id"]);
expect(Quote_Model.associations.chapters.target).toBe(QuoteChapter_Model);
expect(QuoteChapter_Model.associations.quote.target).toBe(Quote_Model);
expect(Quote_Model.getAttributes().show_chapter_line_amounts.defaultValue).toBe(true);
expect(QuoteItem_Model.getAttributes().chapter_id.allowNull).toBe(true);
expect(QuoteItem_Model.getAttributes().chapter_id.defaultValue).toBeNull();
expect(QuoteChapter_Model.options.timestamps).toBe(false);
expect(QuoteChapter_Model.options.indexes).toEqual(expect.arrayContaining([
expect.objectContaining({ fields: ["quote_id", "position"] }),
]));
});

View File

@ -0,0 +1,46 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { Quote_Model } from "./quote.model";
export type QuoteChapterCreationAttributes = InferCreationAttributes<QuoteChapter_Model>;
export class QuoteChapter_Model extends Model<
InferAttributes<QuoteChapter_Model>,
InferCreationAttributes<QuoteChapter_Model>
> {
static associate(connection: Sequelize) {
const { Quote_Model, QuoteChapter_Model } = connection.models;
QuoteChapter_Model.belongsTo(Quote_Model, {
as: "quote",
foreignKey: "quote_id",
onDelete: "CASCADE",
});
}
declare quote_id: string;
declare chapter_id: string;
declare position: number;
declare description: string;
declare quote: NonAttribute<Quote_Model>;
}
export default (sequelize: Sequelize) => {
QuoteChapter_Model.init({
quote_id: { type: DataTypes.UUID, primaryKey: true },
chapter_id: { type: DataTypes.UUID, primaryKey: true },
position: { type: DataTypes.MEDIUMINT, allowNull: false },
description: { type: DataTypes.TEXT, allowNull: false },
}, {
sequelize,
tableName: "quote_chapters",
timestamps: false,
indexes: [{ name: "quote_chapters_position_idx", fields: ["quote_id", "position"] }],
});
return QuoteChapter_Model;
};

View File

@ -30,6 +30,7 @@ export class QuoteItem_Model extends Model<
declare quote_id: string;
declare item_id: string;
declare chapter_id: CreationOptional<string | null>;
declare id_article: CreationOptional<number | null>;
declare position: number;
declare description: CreationOptional<string | null>;
@ -53,6 +54,7 @@ export default (sequelize: Sequelize) => {
type: new DataTypes.UUID(),
primaryKey: true,
},
chapter_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null },
id_article: {
type: DataTypes.BIGINT().UNSIGNED,
allowNull: true,
@ -92,7 +94,7 @@ export default (sequelize: Sequelize) => {
tableName: "quote_items",
timestamps: false,
indexes: [],
indexes: [{ name: "quote_items_chapter_idx", fields: ["quote_id", "chapter_id"] }],
}
);

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Request_DTO, ensureQuoteChapterStructureIsValid } from "../QuoteChapter.dto";
import Joi from "joi";
import {
IMoney_Response_DTO,
@ -19,6 +20,8 @@ export interface ICreateQuote_Request_DTO {
notes: string;
validity: string;
show_line_discount_column?: boolean;
chapters?: IQuoteChapter_Request_DTO[];
show_chapter_line_amounts?: boolean;
additional_currency_code?: string | null;
additional_currency_rate?: number | null;
additional_currency_date?: string | null;
@ -34,6 +37,7 @@ export interface ICreateQuote_Request_DTO {
}
export interface ICreateQuoteItem_Request_DTO {
chapter_id?: string | null;
id_article: string;
quantity: IQuantity_Response_DTO;
description: string;
@ -60,5 +64,5 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
return Result.fail(result.error);
}
return Result.ok(true);
return ensureQuoteChapterStructureIsValid(quoteDTO);
}

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Response_DTO } from "../QuoteChapter.dto";
import { IMoney_DTO, IPercentage_DTO, IQuantity_DTO } from "../../../../../common";
export interface ICreateQuote_Response_DTO {
@ -14,6 +15,8 @@ export interface ICreateQuote_Response_DTO {
notes: string;
validity: string;
show_line_discount_column: boolean;
chapters: IQuoteChapter_Response_DTO[];
show_chapter_line_amounts: boolean;
additional_currency_code: string | null;
additional_currency_rate: number | null;
additional_currency_date: string | null;
@ -34,6 +37,7 @@ export interface ICreateQuote_Response_DTO {
}
export interface ICreateQuote_QuoteItem_Response_DTO {
chapter_id: string | null;
id_article: string;
quantity: IQuantity_DTO;
description: string;

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Response_DTO } from "../QuoteChapter.dto";
import { IMoney_DTO, IPercentage_DTO, IQuantity_DTO } from "../../../../../common";
export interface IDuplicateQuote_Response_DTO {
@ -14,6 +15,8 @@ export interface IDuplicateQuote_Response_DTO {
notes: string;
validity: string;
show_line_discount_column: boolean;
chapters: IQuoteChapter_Response_DTO[];
show_chapter_line_amounts: boolean;
additional_currency_code: string | null;
additional_currency_rate: number | null;
additional_currency_date: string | null;
@ -34,6 +37,7 @@ export interface IDuplicateQuote_Response_DTO {
}
export interface IDuplicateQuote_QuoteItem_Response_DTO {
chapter_id: string | null;
id_article: string;
quantity: IQuantity_DTO;
description: string;

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Response_DTO } from "../QuoteChapter.dto";
import { IMoney_DTO, IPercentage_DTO, IQuantity_DTO } from "../../../../../common";
export interface IGetQuote_Response_DTO {
@ -14,6 +15,8 @@ export interface IGetQuote_Response_DTO {
notes: string;
validity: string;
show_line_discount_column: boolean;
chapters: IQuoteChapter_Response_DTO[];
show_chapter_line_amounts: boolean;
additional_currency_code: string | null;
additional_currency_rate: number | null;
additional_currency_date: string | null;
@ -36,6 +39,7 @@ export interface IGetQuote_Response_DTO {
}
export interface IGetQuote_QuoteItem_Response_DTO {
chapter_id: string | null;
id_article: string;
/** Runtime projection for the authenticated user's language; never persist this field. */
localized_description?: string;

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from "@jest/globals";
import {
ICreateQuote_Request_DTO,
ensureCreateQuote_Request_DTOIsValid,
} from "./CreateQuote.dto";
import {
IUpdateQuote_Request_DTO,
ensureUpdateQuote_Request_DTOIsValid,
} from "./UpdateQuote.dto";
import { ensureQuoteChapterStructureIsValid } from "./QuoteChapter.dto";
const id = "770672b9-5a87-4e8e-a8af-8b44a7432e12";
const otherId = "35ea9365-f3d3-4127-ab31-7be82dcdd006";
const chapter = { id, description: "Armario" };
describe("Quote chapter request contracts", () => {
it.each([
{},
{ items: [{ description: "Legacy text" }] },
{ chapters: [], items: [{ chapter_id: null }] },
{ chapters: [chapter], items: [] },
{ chapters: [chapter], items: [{ chapter_id: id }] },
{ chapters: [chapter, { id: otherId, description: "Interior" }], items: [{ chapter_id: otherId }, { chapter_id: id }] },
{ show_chapter_line_amounts: false },
])("accepts legacy, empty and grouped requests: %j", (dto) => {
expect(ensureQuoteChapterStructureIsValid(dto).isSuccess).toBe(true);
});
it.each([
{ chapters: null },
{ chapters: [{ id: "invalid", description: "Armario" }] },
{ chapters: [{ description: "Missing identity" }] },
{ chapters: [{ id, description: "" }] },
{ chapters: [{ id, description: " \n\t " }] },
{ chapters: [{ id, description: null }] },
{ chapters: [chapter, chapter] },
{ chapters: [{ ...chapter, subtotal_price: { amount: 999 } }] },
{ chapters: [{ ...chapter, chapters: [] }] },
{ items: [{ chapter_id: id }] },
{ chapters: [], items: [{ chapter_id: id }] },
{ chapters: [chapter], items: [{}] },
{ chapters: [chapter], items: [{ chapter_id: null }] },
{ chapters: [chapter], items: [{ chapter_id: otherId }] },
{ chapters: [chapter], items: [{ chapter_id: id }, {}] },
{ show_chapter_line_amounts: "false" },
{ show_chapter_line_amounts: null },
])("rejects invalid chapter structure: %j", (dto) => {
expect(ensureQuoteChapterStructureIsValid(dto).isFailure).toBe(true);
});
it("keeps omission distinct from explicit ungrouping without mutating the request", () => {
const omitted = { items: [{}] };
const ungrouped = { chapters: [], items: [{ chapter_id: null }] };
ensureQuoteChapterStructureIsValid(omitted);
ensureQuoteChapterStructureIsValid(ungrouped);
expect(omitted).not.toHaveProperty("chapters");
expect(omitted).not.toHaveProperty("show_chapter_line_amounts");
expect(ungrouped.chapters).toEqual([]);
});
it.each([
{ name: "create", validate: (dto: object) => ensureCreateQuote_Request_DTOIsValid(dto as ICreateQuote_Request_DTO) },
{ name: "update", validate: (dto: object) => ensureUpdateQuote_Request_DTOIsValid(dto as IUpdateQuote_Request_DTO) },
])("validates chapters through the $name request validator", ({ validate }) => {
expect(validate({ items: [{ description: "Legacy text" }] }).isSuccess).toBe(true);
expect(validate({ chapters: [chapter], items: [{ chapter_id: id }], show_chapter_line_amounts: false }).isSuccess).toBe(true);
expect(validate({ chapters: [chapter], items: [{}] }).isFailure).toBe(true);
expect(validate({ chapters: [chapter, chapter] }).isFailure).toBe(true);
expect(validate({ show_chapter_line_amounts: "false" }).isFailure).toBe(true);
});
});

View File

@ -0,0 +1,48 @@
import Joi from "joi";
import { IMoney_DTO, Result, RuleValidator } from "../../../../common";
export interface IQuoteChapter_Request_DTO {
id: string;
description: string;
}
export interface IQuoteChapter_Response_DTO extends IQuoteChapter_Request_DTO {
subtotal_price: IMoney_DTO;
}
export interface IQuoteChapterStructure_DTO {
chapters?: IQuoteChapter_Request_DTO[];
items?: { chapter_id?: string | null }[];
show_chapter_line_amounts?: boolean;
}
// No defaults here: an omitted chapters field must remain distinguishable from
// explicit ungrouping when the update use case loads the existing quote.
export function ensureQuoteChapterStructureIsValid(dto: unknown) {
const chapterId = Joi.string().guid({ version: ["uuidv4"] });
const schema = Joi.object({
chapters: Joi.array().items(
Joi.object({
id: chapterId.required(),
description: Joi.string().pattern(/\S/).required(),
}).unknown(false)
).unique("id"),
items: Joi.array().items(
Joi.object({ chapter_id: chapterId.allow(null) }).unknown(true)
),
show_chapter_line_amounts: Joi.boolean().strict(),
}).unknown(true).custom((value: IQuoteChapterStructure_DTO, helpers) => {
const chapterIds = new Set((value.chapters ?? []).map((chapter) => chapter.id));
const invalidIndex = (value.items ?? []).findIndex((item) =>
chapterIds.size > 0
? !item.chapter_id || !chapterIds.has(item.chapter_id)
: item.chapter_id != null
);
return invalidIndex === -1
? value
: helpers.message({ custom: `items[${invalidIndex}].chapter_id must reference a chapter of this quote` });
});
const result = RuleValidator.validate<IQuoteChapterStructure_DTO>(schema, dto);
return result.isFailure ? Result.fail(result.error) : Result.ok(true);
}

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Request_DTO, ensureQuoteChapterStructureIsValid } from "../QuoteChapter.dto";
import Joi from "joi";
import {
IMoney_Response_DTO,
@ -19,6 +20,8 @@ export interface IUpdateQuote_Request_DTO {
notes: string;
validity: string;
show_line_discount_column?: boolean;
chapters?: IQuoteChapter_Request_DTO[];
show_chapter_line_amounts?: boolean;
additional_currency_code?: string | null;
additional_currency_rate?: number | null;
additional_currency_date?: string | null;
@ -34,6 +37,7 @@ export interface IUpdateQuote_Request_DTO {
}
export interface IUpdateQuoteItem_Request_DTO {
chapter_id?: string | null;
id_article: string;
quantity: IQuantity_Response_DTO;
description: string;
@ -119,5 +123,5 @@ export function ensureUpdateQuote_Request_DTOIsValid(quoteDTO: IUpdateQuote_Requ
return Result.fail(result.error);
}
return Result.ok(true);
return ensureQuoteChapterStructureIsValid(quoteDTO);
}

View File

@ -1,3 +1,4 @@
import { IQuoteChapter_Response_DTO } from "../QuoteChapter.dto";
import { IMoney_DTO, IPercentage_DTO, IQuantity_DTO } from "../../../../../common";
export interface IUpdateQuote_Response_DTO {
@ -14,6 +15,8 @@ export interface IUpdateQuote_Response_DTO {
notes: string;
validity: string;
show_line_discount_column: boolean;
chapters: IQuoteChapter_Response_DTO[];
show_chapter_line_amounts: boolean;
additional_currency_code: string | null;
additional_currency_rate: number | null;
additional_currency_date: string | null;
@ -34,6 +37,7 @@ export interface IUpdateQuote_Response_DTO {
}
export interface IUpdateQuote_QuoteItem_Response_DTO {
chapter_id: string | null;
id_article: string;
quantity: IQuantity_DTO;
description: string;

View File

@ -5,3 +5,4 @@ export * from "./ListQuotes.dto";
export * from "./SendQuote.dto";
export * from "./SetStatusQuote.dto";
export * from "./UpdateQuote.dto";
export * from "./QuoteChapter.dto";