diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index 9dc851c..37b8a70 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -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", diff --git a/client/src/app/quotes/chapterEditor.test.ts b/client/src/app/quotes/chapterEditor.test.ts new file mode 100644 index 0000000..6362368 --- /dev/null +++ b/client/src/app/quotes/chapterEditor.test.ts @@ -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)); +}); diff --git a/client/src/app/quotes/chapterEditor.ts b/client/src/app/quotes/chapterEditor.ts new file mode 100644 index 0000000..83b99a7 --- /dev/null +++ b/client/src/app/quotes/chapterEditor.ts @@ -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 + ); +} diff --git a/client/src/app/quotes/components/QuoteChapterCreateDialog.tsx b/client/src/app/quotes/components/QuoteChapterCreateDialog.tsx new file mode 100644 index 0000000..3b89b01 --- /dev/null +++ b/client/src/app/quotes/components/QuoteChapterCreateDialog.tsx @@ -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 ( + { + if (!value) close(); + }} + > + { + if (created.current) { + event.preventDefault(); + created.current = false; + } + }} + > + + {t("quotes.chapters.add")} + {t("quotes.chapters.create_description")} + + + setDescription(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + create(); + } + }} + /> + + + + + + + ); +} diff --git a/client/src/app/quotes/components/QuoteChaptersDataTable.tsx b/client/src/app/quotes/components/QuoteChaptersDataTable.tsx new file mode 100644 index 0000000..98ec6e0 --- /dev/null +++ b/client/src/app/quotes/components/QuoteChaptersDataTable.tsx @@ -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 ( + + ); +} + +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 ( + + + + onSelect(Boolean(value))} + aria-label={t("quotes.chapters.select", { number: index + 1 })} + /> + + + + + +
+ + {index + 1}. + + Boolean(value?.trim()) || t("quotes.chapters.title_required"), + }} + render={({ field, fieldState }) => ( +
+