From 64bd7f743d1f51cb47decccd5bf680a42c8a911d Mon Sep 17 00:00:00 2001 From: David Arranz Date: Mon, 19 Oct 2026 13:55:16 +0200 Subject: [PATCH] =?UTF-8?q?Cap=C3=ADtulos=20en=20cotizaciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/.eslintrc.cjs | 2 +- client/src/app/quotes/chapterEditor.test.ts | 162 ++++ client/src/app/quotes/chapterEditor.ts | 203 +++++ .../components/QuoteChapterCreateDialog.tsx | 80 ++ .../components/QuoteChaptersDataTable.tsx | 697 ++++++++++++++++++ .../components/QuoteItemsInlineRowActions.tsx | 13 +- .../QuoteItemsSortableDataTable.tsx | 6 +- .../QuoteItemsSortableDataTableToolbar.tsx | 32 +- .../QuoteItemsSortableDragHandleCell.tsx | 2 +- .../components/QuoteItemsSortableTableRow.tsx | 10 +- .../editors/QuoteChapterEditor.test.tsx | 512 +++++++++++++ .../editors/QuoteDetailsCardEditor.tsx | 92 +-- client/src/app/quotes/edit.tsx | 39 +- client/src/app/quotes/hooks/index.ts | 1 + .../src/app/quotes/hooks/useChapterEditor.ts | 63 ++ .../src/app/quotes/hooks/useDetailColumns.tsx | 15 +- client/src/lib/calc.ts | 17 +- client/src/locales/ca.json | 30 + client/src/locales/en.json | 30 + client/src/locales/es.json | 30 + client/src/locales/fr.json | 30 + client/src/locales/resources.test.ts | 14 + doc/capitulos-cotizaciones.md | 313 ++++++++ doc/capitulos-esquema.md | 47 ++ .../application/Quote/CreateQuote.useCase.ts | 15 + .../Quote/DuplicateQuote.useCase.ts | 28 +- .../Quote/QuoteChapters.useCases.test.ts | 137 ++++ .../application/Quote/UpdateQuote.useCase.ts | 132 ++-- .../services/createQuoteChapters.ts | 19 + .../sales/application/services/index.ts | 1 + .../sales/domain/entities/Quotes/Quote.ts | 65 +- .../entities/Quotes/QuoteChapter.test.ts | 143 ++++ .../domain/entities/Quotes/QuoteChapter.ts | 34 + .../sales/domain/entities/Quotes/QuoteItem.ts | 6 + .../sales/domain/entities/Quotes/index.ts | 1 + .../repository/QuoteRepository.interface.ts | 2 +- .../infrastructure/Quote.repository.test.ts | 125 ++++ .../sales/infrastructure/Quote.repository.ts | 107 +-- .../__fixtures__/quoteChapters.mock.ts | 69 ++ .../quotes/QuoteChapter.presenters.test.ts | 94 +++ .../presenter/CreateQuote.presenter.ts | 7 + .../presenter/DuplicateQuote.presenter.ts | 7 + .../getQuote/presenter/GetQuote.presenter.ts | 7 + .../reporter/ReportQuote.chapters.test.ts | 135 ++++ .../reportQuote/reporter/ReportQuote.i18n.ts | 3 +- .../reporter/ReportQuote.reporter.test.ts | 35 + .../reporter/ReportQuote.reporter.ts | 96 +-- .../reporter/templates/quote/locales/en.json | 2 +- .../reporter/templates/quote/locales/es.json | 2 +- .../reporter/templates/quote/locales/fr.json | 2 +- .../reporter/templates/quote/template.hbs | 51 +- .../presenter/UpdateQuote.presenter.ts | 7 + .../infrastructure/mappers/quote.mapper.ts | 23 +- .../mappers/quoteChapter.mapper.ts | 32 + .../mappers/quoteChapters.mapper.test.ts | 53 ++ .../mappers/quoteItem.mapper.ts | 4 + .../sales/infrastructure/sequelize/index.ts | 1 + .../infrastructure/sequelize/quote.model.ts | 25 +- .../sequelize/quoteChapter.model.test.ts | 25 + .../sequelize/quoteChapter.model.ts | 46 ++ .../sequelize/quoteItem.model.ts | 4 +- .../ICreateQuote_Request.dto.ts | 6 +- .../ICreateQuote_Response.dto.ts | 4 + .../IDuplicateQuote_Response.dto.ts | 4 + .../GetQuote.dto/IGetQuote_Response.dto.ts | 4 + .../dto/Quote/QuoteChapter.dto.test.ts | 71 ++ .../application/dto/Quote/QuoteChapter.dto.ts | 48 ++ .../IUpdateQuote_Request.dto.ts | 6 +- .../IUpdateQuote_Response.dto.ts | 4 + .../sales/application/dto/Quote/index.ts | 1 + 70 files changed, 3842 insertions(+), 291 deletions(-) create mode 100644 client/src/app/quotes/chapterEditor.test.ts create mode 100644 client/src/app/quotes/chapterEditor.ts create mode 100644 client/src/app/quotes/components/QuoteChapterCreateDialog.tsx create mode 100644 client/src/app/quotes/components/QuoteChaptersDataTable.tsx create mode 100644 client/src/app/quotes/components/editors/QuoteChapterEditor.test.tsx create mode 100644 client/src/app/quotes/hooks/useChapterEditor.ts create mode 100644 doc/capitulos-cotizaciones.md create mode 100644 doc/capitulos-esquema.md create mode 100644 server/src/contexts/sales/application/Quote/QuoteChapters.useCases.test.ts create mode 100644 server/src/contexts/sales/application/services/createQuoteChapters.ts create mode 100644 server/src/contexts/sales/domain/entities/Quotes/QuoteChapter.test.ts create mode 100644 server/src/contexts/sales/domain/entities/Quotes/QuoteChapter.ts create mode 100644 server/src/contexts/sales/infrastructure/Quote.repository.test.ts create mode 100644 server/src/contexts/sales/infrastructure/__fixtures__/quoteChapters.mock.ts create mode 100644 server/src/contexts/sales/infrastructure/express/controllers/quotes/QuoteChapter.presenters.test.ts create mode 100644 server/src/contexts/sales/infrastructure/express/controllers/quotes/reportQuote/reporter/ReportQuote.chapters.test.ts create mode 100644 server/src/contexts/sales/infrastructure/mappers/quoteChapter.mapper.ts create mode 100644 server/src/contexts/sales/infrastructure/mappers/quoteChapters.mapper.test.ts create mode 100644 server/src/contexts/sales/infrastructure/sequelize/quoteChapter.model.test.ts create mode 100644 server/src/contexts/sales/infrastructure/sequelize/quoteChapter.model.ts create mode 100644 shared/lib/contexts/sales/application/dto/Quote/QuoteChapter.dto.test.ts create mode 100644 shared/lib/contexts/sales/application/dto/Quote/QuoteChapter.dto.ts 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 }) => ( +
+