feat: enhance chapter management and UI interactions in quotes

- Implement drag-and-drop functionality for chapters using the folder icon as the activator, improving accessibility and user experience.
- Refactor chapter deletion dialog to clearly separate options for keeping or deleting chapter contents, with appropriate translations and UI adjustments.
- Update empty state handling for quotes without items, providing clear actions to add content.
- Adjust translations for clarity and consistency across multiple languages.
- Remove unnecessary empty columns in the chapter details table, streamlining the layout.
- Enhance visual styling with borders and spacing adjustments for better chapter delineation.
- Add tests to cover new functionalities and ensure existing features remain intact.
This commit is contained in:
David Arranz 2026-10-19 17:54:52 +02:00
parent 3b660bf27e
commit e332fcba52
11 changed files with 582 additions and 271 deletions

View File

@ -14,7 +14,6 @@ export const AppendBlockRowButton = forwardRef<HTMLButtonElement, AppendBlockRow
ref
): JSX.Element => (
<Button type='button' variant='outline' ref={ref} {...props}>
{" "}
<PackagePlusIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{label && <>{label}</>}
</Button>

View File

@ -48,8 +48,15 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { CopyIcon, FolderIcon, FolderOpenIcon, GripVerticalIcon, Trash2Icon } from "lucide-react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import {
CopyIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
FolderXIcon,
Trash2Icon,
} from "lucide-react";
import { Fragment, ReactNode, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
@ -111,11 +118,6 @@ function ChapterContent({ children }: { children: ReactNode }) {
);
}
// Reserve the expand column in detail rows so all controls stay aligned.
function ChapterSpacerCell() {
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
}
function EmptyChapterTarget({
id,
columns,
@ -142,8 +144,7 @@ function EmptyChapterTarget({
data-empty-chapter={id}
className={isOver ? "bg-accent" : ""}
>
<ChapterSpacerCell />
<TableCell colSpan={columns - 1} className='p-4'>
<TableCell colSpan={columns} className='p-4'>
<ChapterContent>
<Empty className='border-border bg-muted/20'>
<EmptyHeader>
@ -205,6 +206,10 @@ function ChapterGroup({
className={`bg-background
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b
[&>tr:not([data-chapter-gap]):not([data-active-item])>td]:border-b-border
[&>tr:not([data-chapter-gap]):not([data-active-item])>:first-child]:border-l
[&>tr:not([data-chapter-gap]):not([data-active-item])>:first-child]:border-l-border
[&>tr:not([data-chapter-gap]):not([data-active-item])>:last-child]:border-r
[&>tr:not([data-chapter-gap]):not([data-active-item])>:last-child]:border-r-border
[&>tr[data-active-item]>td]:border-destructive/30
[&>tr[data-active-item]>td:first-child]:border-l-4
[&>tr[data-active-item]>td:first-child]:border-l-destructive
@ -227,39 +232,50 @@ function ChapterGroup({
className='group/chapter bg-muted hover:bg-muted [&>td]:border-t [&>td]:py-3 [&>td:first-child]:rounded-tl-md [&>td:last-child]:rounded-tr-md'
data-chapter-id={chapter.id}
>
<TableCell className='pl-3.5 pr-1'>
<div className='relative w-8 h-8'>
<Button
type='button'
variant='ghost'
size='icon'
className='w-8 h-8 text-muted-foreground'
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
})}
onClick={() => {
toggle();
onActivate();
}}
>
{collapsed ? (
<FolderIcon className='w-5 h-5' aria-hidden='true' />
) : (
<FolderOpenIcon className='w-5 h-5' aria-hidden='true' />
)}
</Button>
<Badge
aria-hidden='true'
variant='secondary'
className='pointer-events-none absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center border-background bg-zinc-700 px-1 py-0 text-[10px] leading-none text-white hover:bg-zinc-700'
>
{index + 1}
</Badge>
</div>
</TableCell>
<TableCell colSpan={columns - 1} className='px-2'>
<TableCell colSpan={columns} className='px-2'>
<div className='flex items-start gap-2'>
<div className='relative self-center shrink-0 w-8 h-8 ml-1.5 mr-2.5'>
<Button
type='button'
variant='ghost'
size='icon'
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='w-8 h-8 touch-none cursor-grab active:cursor-grabbing text-muted-foreground'
title={t("quotes.chapters.folder_help")}
aria-description={t("quotes.chapters.folder_help")}
aria-expanded={!collapsed}
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
number: index + 1,
})}
onFocus={onActivate}
onKeyDown={(event) => {
// Enter keeps native folding; Space starts keyboard dragging.
// During a drag, Enter still confirms the move.
if (event.code === "Enter" && !isDragging) return;
listeners?.onKeyDown?.(event);
}}
onClick={() => {
if (isDragging) return;
toggle();
onActivate();
}}
>
{collapsed ? (
<FolderIcon className='w-5 h-5' aria-hidden='true' />
) : (
<FolderOpenIcon className='w-5 h-5' aria-hidden='true' />
)}
</Button>
<Badge
aria-hidden='true'
variant='secondary'
className='pointer-events-none absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center border-background bg-zinc-700 px-1 py-0 text-[10px] leading-none text-white hover:bg-zinc-700'
>
{index + 1}
</Badge>
</div>
<Controller
key={`${chapter.id}:${index}`}
control={control}
@ -269,7 +285,7 @@ function ChapterGroup({
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
}}
render={({ field, fieldState }) => (
<div className='-ml-px flex-1 min-w-0'>
<div className='flex-1 min-w-0 -ml-px'>
<Textarea
{...field}
value={field.value ?? ""}
@ -287,19 +303,6 @@ function ChapterGroup({
</div>
)}
/>
<Button
type='button'
variant='ghost'
size='icon'
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className='w-10 h-10 shrink-0 touch-none cursor-grab opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
onFocus={onActivate}
aria-label={t("quotes.chapters.move", { number: index + 1 })}
>
<GripVerticalIcon className='w-4 h-4' />
</Button>
<Button
type='button'
variant='ghost'
@ -328,8 +331,7 @@ function ChapterGroup({
className='bg-muted/60 font-semibold hover:bg-muted/60 [&>td]:border-t-2 [&>td]:py-3 [&>td:first-child]:rounded-bl-md [&>td:last-child]:rounded-br-md'
data-chapter-footer={chapter.id}
>
<ChapterSpacerCell />
<TableCell colSpan={columns - 2} className='pl-4 text-sm text-muted-foreground'>
<TableCell colSpan={columns - 1} className='pl-4 text-sm text-muted-foreground'>
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
</TableCell>
<TableCell className='text-base text-right whitespace-nowrap'>{subtotal}</TableCell>
@ -437,23 +439,9 @@ export function QuoteChaptersDataTable({
)
);
};
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
() => [
{
id: "chapter_tree",
header: () => null,
cell: () => null,
size: 56,
enableHiding: false,
enableSorting: false,
},
...columns,
],
[columns]
);
const table = useReactTable({
data,
columns: chapterColumns,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
state: { rowSelection, columnVisibility: { discount: showLineDiscountColumn } },
@ -524,6 +512,7 @@ export function QuoteChaptersDataTable({
controller.setActiveId(chapterId);
}
};
const deletingChapters = Boolean(deleting?.chapters.length);
const destinations = chapters.filter((chapter) => !deleting?.chapters.includes(chapter.id));
const affectedCount = deleting
? items.filter(
@ -575,6 +564,7 @@ export function QuoteChaptersDataTable({
extraControls={
<>
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
<FolderPlusIcon className='w-4 h-4 sm:mr-2' />
{t("quotes.chapters.add")}
</Button>
<Button
@ -583,6 +573,7 @@ export function QuoteChaptersDataTable({
size='sm'
onClick={() => setUngrouping(true)}
>
<FolderXIcon className='w-4 h-4 sm:mr-2' />
{t("quotes.chapters.ungroup")}
</Button>
</>
@ -690,27 +681,20 @@ export function QuoteChaptersDataTable({
controller.setActiveId(chapter.id);
}}
>
{row.getVisibleCells().map((cell) =>
cell.column.id === "chapter_tree" ? (
<ChapterSpacerCell key={cell.id} />
) : (
<TableCell
key={cell.id}
className={
cell.column.id === "description"
? "py-2 pl-4 pr-2 align-top"
: "px-2 py-2 align-top"
}
>
<ChapterContent>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</ChapterContent>
</TableCell>
)
)}
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={
cell.column.id === "description"
? "py-2 pl-4 pr-2 align-top"
: "px-2 py-2 align-top"
}
>
<ChapterContent>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ChapterContent>
</TableCell>
))}
</QuoteItemsSortableTableRow>
{activeRow === row.id && (
<TableRow
@ -771,51 +755,97 @@ export function QuoteChaptersDataTable({
if (!open) setDeleting(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("quotes.chapters.delete_title")}</DialogTitle>
<DialogContent className='max-h-[85vh] w-[calc(100%_-_2rem)] overflow-y-auto rounded-lg sm:max-w-xl'>
<DialogHeader className='pr-5 text-left'>
<DialogTitle>
{t(deletingChapters ? "quotes.chapters.delete_chapter_title" : "quotes.chapters.delete_title")}
</DialogTitle>
<DialogDescription>
{t("quotes.chapters.delete_description", {
chapters: deleting?.chapters.length ?? 0,
details: affectedCount,
})}
{t(deletingChapters
? "quotes.chapters.delete_chapter_description"
: "quotes.chapters.delete_details_description", { 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")}
{deletingChapters ? (
<div className='space-y-4'>
<section aria-labelledby='chapter-keep-title' className='space-y-3 rounded-lg border border-border bg-muted/20 p-4'>
<div className='space-y-1'>
<h3 id='chapter-keep-title' className='text-sm font-semibold'>
{t("quotes.chapters.keep_details_title")}
</h3>
<p className='text-sm text-muted-foreground'>
{t(destinations.length
? "quotes.chapters.transfer_description"
: "quotes.chapters.keep_ungroup_description", { details: affectedCount })}
</p>
</div>
{destinations.length ? (
<>
<div className='space-y-2'>
<Label htmlFor='chapter-transfer'>{t("quotes.chapters.destination")}</Label>
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger id='chapter-transfer'>
<SelectValue placeholder={t("quotes.chapters.choose_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>
</div>
<Button
type='button'
variant='outline'
className='h-auto min-h-10 w-full whitespace-normal'
disabled={!targetId}
onClick={() => finishRemove("transfer")}
>
{t("quotes.chapters.transfer_action")}
</Button>
</>
) : (
<Button
type='button'
variant='outline'
className='h-auto min-h-10 w-full whitespace-normal'
onClick={() => finishRemove("ungroup")}
>
{t("quotes.chapters.keep_ungroup_action")}
</Button>
)}
</section>
<section aria-labelledby='chapter-delete-content-title' className='space-y-3 rounded-lg border border-destructive/20 bg-destructive/5 p-4'>
<div className='space-y-1'>
<h3 id='chapter-delete-content-title' className='text-sm font-semibold text-destructive'>
{t("quotes.chapters.delete_all_title")}
</h3>
<p className='text-sm text-muted-foreground'>
{t("quotes.chapters.delete_all_description", { details: affectedCount })}
</p>
</div>
<Button
type='button'
variant='destructive'
className='h-auto min-h-10 w-full whitespace-normal'
onClick={() => finishRemove("delete")}
>
{t("quotes.chapters.delete_all_title")}
</Button>
) : (
<Button type='button' onClick={() => finishRemove("ungroup")}>
{t("quotes.chapters.ungroup_keep")}
</Button>
))}
</section>
</div>
) : (
<Button type='button' variant='destructive' onClick={() => finishRemove("delete")}>
{t("quotes.chapters.delete_content")}
</Button>
)}
<DialogFooter>
<Button type='button' variant='secondary' className='w-full sm:w-auto' onClick={() => setDeleting(null)}>
{t("common.cancel")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -1,5 +1,18 @@
import { ButtonGroup, DataTableColumnHeader } from "@/components";
import { Badge, Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/ui";
import {
Badge,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from "@/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table";
import {
DndContext,
@ -32,12 +45,14 @@ import {
} from "@tanstack/react-table";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { ListPlusIcon } from "lucide-react";
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
export type RowIdData = { [x: string]: any };
@ -66,7 +81,7 @@ export interface QuoteItemsSortableProps {
export type QuoteItemsSortableDataTableProps<
TData extends RowData & RowIdData,
TValue = unknown
TValue = unknown,
> = {
extraControls?: ReactNode;
columns: ColumnDef<TData, TValue>[];
@ -123,6 +138,8 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
hasLineDiscounts,
onShowLineDiscountColumnChange,
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
const { t } = useTranslation();
const isEmpty = data.length === 0;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
const [activeRowId, setActiveRowId] = useState<string | null>(null);
@ -183,7 +200,8 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
},
duplicateItems: (rowIndex?: number) => {
if (rowIndex != undefined) {
const originalData = actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
const originalData =
actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, { ...originalData, id: undefined }, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) {
const lastIndex =
@ -314,79 +332,105 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
</CardTitle>
</CardHeader>
<CardContent>
<Table className='table-fixed'>
<TableHeader className='sticky top-0 z-10 bg-background'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => {
{isEmpty ? (
<Empty className='border-border bg-muted/20 p-4 sm:p-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<ListPlusIcon aria-hidden='true' />
</EmptyMedia>
<EmptyTitle>{t("quotes.empty_details.title")}</EmptyTitle>
<EmptyDescription>{t("quotes.empty_details.description")}</EmptyDescription>
</EmptyHeader>
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
<AppendEmptyRowButton size='sm' onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
size='sm'
onClick={() => table.options.meta?.pickCatalogArticle?.()}
/>
<AppendBlockRowButton
size='sm'
onClick={() => table.options.meta?.pickBlock?.()}
/>
</EmptyContent>
</Empty>
) : (
<Table className='table-fixed'>
<TableHeader className='sticky top-0 z-10 bg-background'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className='px-2 py-1'
style={{
width:
header.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: header.getSize(),
}}
>
{header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} />
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
<SortableContext
items={filterItems(sorteableRowIds)}
strategy={verticalListSortingStrategy}
>
{filterItems(table.getRowModel().rows).map((row) => {
const typedRow = row as Row<TData>;
const isActive = typedRow.id === activeRowId;
return (
<TableHead
key={header.id}
className='px-2 py-1'
style={{
width:
header.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: header.getSize(),
}}
>
{header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} />
<Fragment key={typedRow.id}>
<QuoteItemsSortableTableRow
id={typedRow.id}
isActive={isActive}
onActivate={() => setActiveRowId(typedRow.id)}
>
{typedRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{isActive && (
<TableRow className='border border-t-0 border-l-4 border-destructive/30 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
<TableCell
colSpan={typedRow.getVisibleCells().length}
className='px-2 py-2'
>
<QuoteItemsInlineRowActions
row={typedRow}
table={table}
onDelete={() => setActiveRowId(null)}
/>
</TableCell>
</TableRow>
)}
</TableHead>
</Fragment>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
<SortableContext
items={filterItems(sorteableRowIds)}
strategy={verticalListSortingStrategy}
>
{filterItems(table.getRowModel().rows).map((row) => {
const typedRow = row as Row<TData>;
const isActive = typedRow.id === activeRowId;
return (
<Fragment key={typedRow.id}>
<QuoteItemsSortableTableRow
id={typedRow.id}
isActive={isActive}
onActivate={() => setActiveRowId(typedRow.id)}
>
{typedRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{isActive && (
<TableRow className='border border-t-0 border-destructive/30 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
<TableCell colSpan={typedRow.getVisibleCells().length} className='px-2 py-2'>
<QuoteItemsInlineRowActions
row={typedRow}
table={table}
onDelete={() => setActiveRowId(null)}
/>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</SortableContext>
</TableBody>
</Table>
</SortableContext>
</TableBody>
</Table>
)}
{createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
@ -522,25 +566,27 @@ export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, T
document.body
)}
</CardContent>
<CardFooter>
<ButtonGroup>
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
<AppendBlockRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
</ButtonGroup>
</CardFooter>
{!isEmpty && (
<CardFooter>
<ButtonGroup>
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
<AppendBlockRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
</ButtonGroup>
</CardFooter>
)}
</Card>
</DndContext>
);

View File

@ -7,8 +7,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Switch,
Label,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
@ -33,7 +33,12 @@ export const QuoteItemsSortableDataTableToolbar = ({
table: Table<any>;
extraControls?: ReactNode;
viewControls?: ReactNode;
selectionActions?: { count: number; duplicate: () => void; remove: () => void; clear: () => void };
selectionActions?: {
count: number;
duplicate: () => void;
remove: () => void;
clear: () => void;
};
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
@ -52,9 +57,9 @@ export const QuoteItemsSortableDataTableToolbar = ({
const discountColumnControl = (
<>
<div className='flex min-h-9 items-center gap-2'>
<div className='flex items-center gap-2 min-h-9'>
<div className='grid gap-0.5'>
<Label htmlFor="show-line-discount-column">{t("quotes.discount_column.label")}</Label>
<Label htmlFor='show-line-discount-column'>{t("quotes.discount_column.label")}</Label>
</div>
<Switch
id='show-line-discount-column'
@ -90,8 +95,11 @@ export const QuoteItemsSortableDataTableToolbar = ({
);
return (
<nav className='flex flex-wrap items-center gap-3 rounded-lg border border-border bg-muted/40 p-2'>
<ButtonGroup role='group' className='flex-wrap gap-1 rounded-md border border-border bg-background p-1 shadow-sm'>
<nav className='flex flex-wrap items-center gap-3'>
<ButtonGroup
role='group'
className='flex-wrap gap-1 p-1 border rounded-md shadow-sm border-border bg-background'
>
{selectedRowsCount > 0 ? (
<>
<Tooltip>
@ -100,10 +108,16 @@ export const QuoteItemsSortableDataTableToolbar = ({
type='button'
variant='ghost'
size='sm'
onClick={() => selectionActions ? selectionActions.duplicate() : table.options.meta?.duplicateItems()}
onClick={() =>
selectionActions
? selectionActions.duplicate()
: table.options.meta?.duplicateItems()
}
>
<CopyPlusIcon className='h-4 w-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.duplicate_selected_rows")}</span>
<CopyPlusIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
@ -115,9 +129,11 @@ export const QuoteItemsSortableDataTableToolbar = ({
variant='ghost'
size='sm'
className='text-destructive hover:bg-destructive/10 hover:text-destructive'
onClick={() => selectionActions ? selectionActions.remove() : table.options.meta?.deleteItems()}
onClick={() =>
selectionActions ? selectionActions.remove() : table.options.meta?.deleteItems()
}
>
<Trash2Icon className='h-4 w-4 sm:mr-2' />
<Trash2Icon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
@ -129,9 +145,11 @@ export const QuoteItemsSortableDataTableToolbar = ({
type='button'
variant='ghost'
size='sm'
onClick={() => selectionActions ? selectionActions.clear() : table.resetRowSelection()}
onClick={() =>
selectionActions ? selectionActions.clear() : table.resetRowSelection()
}
>
<ScanIcon className='h-4 w-4 sm:mr-2' />
<ScanIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.reset_selected_rows")}</span>
</Button>
</TooltipTrigger>
@ -142,19 +160,31 @@ export const QuoteItemsSortableDataTableToolbar = ({
<>
<Tooltip>
<TooltipTrigger asChild>
<AppendEmptyRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.appendItem()} />
<AppendEmptyRowButton
variant='ghost'
size='sm'
onClick={() => table.options.meta?.appendItem()}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendCatalogArticleRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.pickCatalogArticle?.()} />
<AppendCatalogArticleRowButton
variant='ghost'
size='sm'
onClick={() => table.options.meta?.pickCatalogArticle?.()}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_article_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendBlockRowButton variant='ghost' size='sm' onClick={() => table.options.meta?.pickBlock?.()} />
<AppendBlockRowButton
variant='ghost'
size='sm'
onClick={() => table.options.meta?.pickBlock?.()}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
</Tooltip>
@ -162,16 +192,20 @@ export const QuoteItemsSortableDataTableToolbar = ({
)}
</ButtonGroup>
{selectedRowsCount > 0 && (
<p className='whitespace-nowrap px-1 text-sm text-muted-foreground'>
<p className='px-1 text-sm whitespace-nowrap text-muted-foreground'>
{t("common.rows_selected", { count: selectedRowsCount })}
</p>
)}
{extraControls && (
<ButtonGroup role='group' className='flex-wrap gap-1 rounded-md border border-border bg-background p-1 shadow-sm'>
{extraControls && selectedRowsCount === 0 && (
<ButtonGroup
role='group'
className='flex-wrap gap-1 p-1 border rounded-md shadow-sm border-border bg-background'
>
{extraControls}
</ButtonGroup>
)}
<div className='flex w-full flex-wrap items-center gap-x-4 gap-y-2 border-t border-border pt-2 xl:ml-auto xl:w-auto xl:border-t-0 xl:pt-0'>
<div className='flex flex-wrap items-center w-full pt-2 border-t gap-x-4 gap-y-2 border-border xl:ml-auto xl:w-auto xl:border-t-0 xl:pt-0'>
{viewControls}
{discountColumnControl}
</div>

View File

@ -103,6 +103,9 @@ afterEach(async () => {
let currentForm: UseFormReturn<FieldValues>;
let dragProps: DndContextProps;
const mockChapterPointerDown = jest.fn();
const mockChapterKeyDown = jest.fn();
const mockChapterActivator = jest.fn();
jest.mock("@dnd-kit/core", () => {
const actual = jest.requireActual("@dnd-kit/core");
@ -117,11 +120,13 @@ jest.mock("@dnd-kit/core", () => {
});
jest.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useSortable: () => ({
useSortable: ({ id }: { id: string }) => ({
attributes: {},
listeners: {},
listeners: id.startsWith("chapter:")
? { onPointerDown: mockChapterPointerDown, onKeyDown: mockChapterKeyDown }
: {},
setNodeRef: () => {},
setActivatorNodeRef: () => {},
setActivatorNodeRef: id.startsWith("chapter:") ? mockChapterActivator : () => {},
transform: null,
}),
verticalListSortingStrategy: {},
@ -211,7 +216,7 @@ const item = (chapter_id: string | null = null) => ({
});
const currency = CurrencyData.createFromCode("EUR").object;
const language = Language.createFromCode("es").object;
function Harness({ grouped = false }: { grouped?: boolean }) {
function Harness({ grouped = false, empty = false }: { grouped?: boolean; empty?: boolean }) {
const form = useForm({
defaultValues: {
status: "draft",
@ -230,7 +235,7 @@ function Harness({ grouped = false }: { grouped?: boolean }) {
{ id: chapterB, description: "Interior", subtotal_price: { amount: 0 } },
]
: [],
items: [item(grouped ? chapterA : null)],
items: empty ? [] : [item(grouped ? chapterA : null)],
show_line_discount_column: true,
show_chapter_line_amounts: true,
},
@ -350,14 +355,14 @@ it("cancels deletion, deletes empty chapters directly and confirms content delet
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"));
fireEvent.click(screen.getByText("Quitar capítulo y conservar conceptos"));
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"));
fireEvent.click(screen.getByLabelText("Importes por línea en 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" });
@ -386,24 +391,37 @@ it("keeps inserted details inside their chapter and duplicates edited form value
).toBe(true);
});
it("transfers chapter content using the destination dialog", async () => {
it("separates keeping and deleting chapter contents and requires a destination before transfer", async () => {
render(<Harness grouped />);
const before = snapshot().items[0];
fireEvent.click(screen.getByLabelText("Eliminar capítulo 1"));
const dialog = screen.getByRole("dialog");
const keep = dialog.querySelector<HTMLElement>('section[aria-labelledby="chapter-keep-title"]')!;
const remove = dialog.querySelector<HTMLElement>('section[aria-labelledby="chapter-delete-content-title"]')!;
const transfer = within(keep).getByText("Trasladar conceptos y quitar capítulo") as HTMLButtonElement;
expect(keep.contains(screen.getByRole("combobox"))).toBe(true);
expect(remove.contains(screen.getByText("Eliminar capítulo y conceptos"))).toBe(true);
expect(keep.contains(screen.getByText("Cancelar"))).toBe(false);
expect(remove.contains(screen.getByText("Cancelar"))).toBe(false);
expect(transfer.disabled).toBe(true);
fireEvent.click(transfer);
expect(snapshot().items).toEqual([before]);
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(transfer.disabled).toBe(false);
fireEvent.click(transfer);
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
expect(snapshot().items[0].chapter_id).toBe(chapterB);
expect(snapshot().items).toEqual([{ ...before, chapter_id: 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"));
fireEvent.click(screen.getByText("Eliminar capítulo y conceptos"));
expect(snapshot().items).toEqual([]);
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
});
@ -416,7 +434,7 @@ it("selects only detail rows and keeps chapter controls available", () => {
const row = screen.getByLabelText("items.0.description").closest("tr")!;
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
expect(within(row).getByLabelText("Seleccionar fila").getAttribute("aria-checked")).toBe("true");
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByLabelText("Plegar capítulo 1") as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(screen.getByText("Duplicar"));
expect(snapshot().chapters).toHaveLength(2);
expect(snapshot().items).toHaveLength(2);
@ -519,7 +537,7 @@ it("keeps folding attached to the chapter when reordered and opens duplicates in
await act(async () => { await Promise.resolve(); });
});
it.each([true, false])("keeps the hierarchy column aligned with headers, details, actions and subtotals (discounts=%s)", async (discounts) => {
it.each([true, false])("keeps headers, details, actions and subtotals aligned without an extra chapter column (discounts=%s)", async (discounts) => {
render(<Harness grouped />);
await act(async () => {
currentForm.setValue("show_line_discount_column", discounts);
@ -530,7 +548,7 @@ it.each([true, false])("keeps the hierarchy column aligned with headers, details
const coveredColumns = (row: HTMLTableRowElement) =>
Array.from(row.cells).reduce((count, cell) => count + cell.colSpan, 0);
expect(table.tBodies).toHaveLength(2);
expect(screen.getByLabelText("items.0.description").closest("tr")!.cells[0].dataset.chapterColumn).toBe("chapter_tree");
expect(screen.getByLabelText("items.0.description").closest("tr")!.cells[0].textContent).toContain(i18n.t("common.move_row"));
fireEvent.focus(screen.getByLabelText("items.0.description"));
expect(Array.from(table.rows).map(coveredColumns)).toEqual(Array(table.rows.length).fill(columns));
fireEvent.click(screen.getByLabelText("Plegar capítulo 1"));
@ -539,12 +557,12 @@ it.each([true, false])("keeps the hierarchy column aligned with headers, details
expect(ensureUpdateQuote_Request_DTOIsValid(before).isSuccess).toBe(true);
});
it.each([true, false])("orders expand, drag and select controls with the expand column only in chapter mode (grouped=%s)", (grouped) => {
it.each([true, false])("starts detail rows with drag and select controls in both table modes (grouped=%s)", (grouped) => {
render(<Harness grouped={grouped} />);
const field = screen.getByLabelText("items.0.description");
const row = field.closest("tr")!;
const table = row.closest("table")!;
const dragIndex = grouped ? 1 : 0;
const dragIndex = 0;
const selectIndex = dragIndex + 1;
expect(row.cells[dragIndex].textContent).toContain(i18n.t("common.move_row"));
expect(row.cells[selectIndex].querySelector('[role="checkbox"]')).toBeTruthy();
@ -552,11 +570,11 @@ it.each([true, false])("orders expand, drag and select controls with the expand
expect(header.cells[selectIndex].querySelector('[role="checkbox"]')).toBeTruthy();
expect(field.closest("td")).toBe(row.cells[selectIndex + 1]);
expect(screen.getByLabelText("items.0.quantity").closest("td")).toBe(row.cells[selectIndex + 2]);
expect(table.querySelectorAll('[data-chapter-column="chapter_tree"]').length > 0).toBe(grouped);
expect(table.querySelectorAll('[data-chapter-column="chapter_tree"]')).toHaveLength(0);
if (grouped) {
const heading = table.querySelector<HTMLTableRowElement>(`[data-chapter-id="${chapterA}"]`)!;
expect(screen.getByLabelText("Plegar capítulo 1").closest("td")).toBe(heading.cells[0]);
expect(screen.getByLabelText("Mover capítulo 1").closest("td")).toBe(heading.cells[1]);
expect(screen.getByLabelText("Título del capítulo 1").closest("td")).toBe(heading.cells[0]);
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
}
});
@ -639,8 +657,9 @@ it("shows chapter numbers as badges on the folder controls, including after reor
expect(badge.textContent).toBe(String(number));
expect(toggle.querySelector("svg")).toBeTruthy();
expect(toggle.getAttribute("aria-label")).toContain(String(number));
expect(heading.cells[1].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
expect(heading.cells[1].textContent).not.toMatch(/^\s*\d+/);
const title = heading.querySelector('textarea')!.parentElement!;
expect(title.querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
expect(title.textContent).not.toMatch(/^\s*\d+/);
};
assertBadge(chapterA, 1);
assertBadge(chapterB, 2);
@ -718,3 +737,74 @@ it("folds immediately without animation when reduced motion is preferred", () =>
window.matchMedia = originalMatchMedia;
}
});
it("uses the folder as the chapter drag activator while preserving click and Enter for folding", () => {
mockChapterPointerDown.mockClear();
mockChapterKeyDown.mockClear();
mockChapterActivator.mockClear();
render(<Harness grouped />);
const folder = screen.getByLabelText("Plegar capítulo 1");
expect(mockChapterActivator).toHaveBeenCalledWith(folder);
expect(folder.getAttribute("title")).toBe(i18n.t("quotes.chapters.folder_help"));
expect(folder.getAttribute("aria-description")).toBe(folder.getAttribute("title"));
const heading = folder.closest("tr")!;
expect(heading.querySelectorAll("button")).toHaveLength(3);
expect(heading.querySelector("svg.lucide-grip-vertical")).toBeNull();
act(() => { folder.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); });
expect(mockChapterPointerDown).toHaveBeenCalledTimes(1);
fireEvent.keyDown(folder, { key: "Enter", code: "Enter" });
expect(mockChapterKeyDown).not.toHaveBeenCalled();
fireEvent.keyDown(folder, { key: " ", code: "Space" });
expect(mockChapterKeyDown).toHaveBeenCalledTimes(1);
const before = JSON.stringify(snapshot());
fireEvent.click(folder);
expect(folder.getAttribute("aria-expanded")).toBe("false");
expect(JSON.stringify(snapshot())).toBe(before);
});
it("offers only detail deletion when no chapter is being removed", () => {
render(<Harness grouped />);
fireEvent.click(screen.getByLabelText("Seleccionar fila"));
fireEvent.click(screen.getByText("Eliminar"));
const dialog = screen.getByRole("dialog");
expect(dialog.querySelector('[role="combobox"]')).toBeNull();
expect(dialog.querySelector('section[aria-labelledby="chapter-keep-title"]')).toBeNull();
expect(dialog.textContent).toContain("sus capítulos se conservarán.");
fireEvent.click(within(dialog).getByText("Eliminar contenido seleccionado"));
expect(snapshot().items).toEqual([]);
expect(snapshot().chapters.map((chapter: { id: string }) => chapter.id)).toEqual([chapterA, chapterB]);
});
it("shows Empty for a new quote without items and adds its first row without creating chapters", () => {
render(<Harness empty />);
const empty = container.querySelector<HTMLElement>('[data-slot="empty"]')!;
expect(empty.textContent).toContain("Esta cotización todavía no tiene conceptos");
expect(container.querySelector("table")).toBeNull();
expect(snapshot().items).toEqual([]);
expect(snapshot().chapters).toEqual([]);
fireEvent.click(within(empty).getByText(i18n.t("common.append_empty_row")));
expect(snapshot().items).toHaveLength(1);
expect(snapshot().items[0].chapter_id).toBeNull();
expect(snapshot().chapters).toEqual([]);
expect(container.querySelector('[data-slot="empty"]')).toBeNull();
expect(screen.getByLabelText("items.0.description").closest("table")).toBeTruthy();
});
it.each([
["common.append_article", "Elegir artículo de prueba", "Catálogo"],
["common.append_block", "Elegir bloque de prueba", "Bloque\nTexto"],
])("adds the first content from the new quote Empty action (%s)", (label, choice, description) => {
render(<Harness empty />);
const empty = container.querySelector<HTMLElement>('[data-slot="empty"]')!;
fireEvent.click(within(empty).getByText(i18n.t(label)));
expect(snapshot().items).toEqual([]);
fireEvent.click(screen.getByText(choice));
expect(snapshot().items).toHaveLength(1);
expect(snapshot().items[0]).toMatchObject({ description });
expect(snapshot().items[0].chapter_id ?? null).toBeNull();
expect(snapshot().chapters).toEqual([]);
expect(container.querySelector('[data-slot="empty"]')).toBeNull();
});

View File

@ -11,6 +11,7 @@ import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/
import { useToast } from "@/ui/use-toast";
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
import { ColumnDef } from "@tanstack/react-table";
import { FolderPlusIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -298,6 +299,7 @@ export const QuoteDetailsCardEditor = ({
defaultValues={defaultValues}
extraControls={
<Button type='button' variant='ghost' size='sm' onClick={() => setCreateChapterOpen(true)}>
<FolderPlusIcon className='w-4 h-4 sm:mr-2' />
{t("quotes.chapters.add")}
</Button>
}

View File

@ -43,6 +43,10 @@
"downloading_dialog": {
"error": "No s’ha pogut descarregar el pressupost. Torna-ho a provar."
},
"empty_details": {
"title": "Aquesta cotització encara no té conceptes",
"description": "Afegeix una fila buida, tria articles del catàleg o incorpora un bloc de text."
},
"chapters": {
"add": "Afegir capítol",
"create_description": "En crear el primer capítol s’agruparan tots els detalls existents.",
@ -50,6 +54,7 @@
"title": "Títol del capítol {{number}}",
"title_required": "La descripció del capítol és obligatòria.",
"select": "Seleccionar capítol {{number}}",
"folder_help": "Arrossega per moure tot el capítol. Clic o Retorn per plegar/desplegar; Espai per moure amb el teclat.",
"move": "Moure capítol {{number}}",
"collapse": "Plegar capítol {{number}}",
"expand": "Desplegar capítol {{number}}",
@ -59,13 +64,24 @@
"empty_title": "Aquest capítol no té detalls",
"empty": "Afegeix una fila, articles o un bloc de text, o arrossega detalls aquí.",
"select_details": "Seleccionar els detalls del capítol {{number}}",
"show_amounts": "Mostrar imports dels detalls al PDF",
"show_amounts": "Imports per línia 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_chapter_title": "Eliminar capítol",
"delete_chapter_description": "Conceptes afectats: {{details}}. Tria si vols conservar-los o eliminar-los.",
"delete_details_description": "Conceptes seleccionats: {{details}}. Només s’eliminaran aquests conceptes; es conservaran els seus capítols.",
"keep_details_title": "Conservar conceptes",
"choose_destination": "Selecciona un altre capítol",
"transfer_description": "Es traurà el capítol. Els seus conceptes es traslladaran al destí que triïs i conservaran els imports.",
"transfer_action": "Traslladar conceptes i treure capítol",
"keep_ungroup_description": "És l’últim capítol. En treure’l, els seus conceptes quedaran sense agrupar i conservaran els imports.",
"keep_ungroup_action": "Treure capítol i conservar conceptes",
"delete_all_title": "Eliminar capítol i conceptes",
"delete_all_description": "S’eliminaran de la cotització el capítol i tots els seus conceptes.",
"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ó",

View File

@ -499,6 +499,10 @@
}
}
},
"empty_details": {
"title": "This quote has no items yet",
"description": "Add an empty row, choose catalog articles or insert a text block."
},
"chapters": {
"add": "Add chapter",
"create_description": "Creating the first chapter groups all existing details.",
@ -506,6 +510,7 @@
"title": "Chapter {{number}} title",
"title_required": "A chapter description is required.",
"select": "Select chapter {{number}}",
"folder_help": "Drag to move the whole chapter. Click or Enter to collapse/expand; Space to move with the keyboard.",
"move": "Move chapter {{number}}",
"collapse": "Collapse chapter {{number}}",
"expand": "Expand chapter {{number}}",
@ -515,13 +520,24 @@
"empty_title": "This chapter has no details",
"empty": "Add a row, articles or a text block, or drag details here.",
"select_details": "Select details in chapter {{number}}",
"show_amounts": "Show detail amounts in the PDF",
"show_amounts": "Line amounts in 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_chapter_title": "Delete chapter",
"delete_chapter_description": "Items affected: {{details}}. Choose whether to keep them or delete them.",
"delete_details_description": "Selected items: {{details}}. Only these items will be deleted; their chapters will be kept.",
"keep_details_title": "Keep items",
"choose_destination": "Select another chapter",
"transfer_description": "The chapter will be removed. Its items will be moved to the destination you choose and keep their amounts.",
"transfer_action": "Move items and remove chapter",
"keep_ungroup_description": "This is the last chapter. Removing it will leave its items ungrouped and keep their amounts.",
"keep_ungroup_action": "Remove chapter and keep items",
"delete_all_title": "Delete chapter and items",
"delete_all_description": "The chapter and all its items will be deleted from the quote.",
"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",

View File

@ -337,7 +337,7 @@
"toast_status_changed": "Estado de la cotización cambiado a: {{newStatus}}"
},
"discount_column": {
"label": "Mostrar columna descuento",
"label": "Ver columna descuento",
"description": "Muestra u oculta la columna de descuento en las líneas.",
"confirmation": {
"title": "Ocultar descuentos de línea",
@ -499,6 +499,10 @@
}
}
},
"empty_details": {
"title": "Esta cotización todavía no tiene conceptos",
"description": "Añade una fila vacía, elige artículos del catálogo o incorpora un bloque de texto."
},
"chapters": {
"add": "Añadir capítulo",
"create_description": "Al crear el primer capítulo se agruparán todos los detalles existentes.",
@ -506,6 +510,7 @@
"title": "Título del capítulo {{number}}",
"title_required": "La descripción del capítulo es obligatoria.",
"select": "Seleccionar capítulo {{number}}",
"folder_help": "Arrastra para mover el capítulo completo. Clic o Entrar para plegar/desplegar; Espacio para mover con teclado.",
"move": "Mover capítulo {{number}}",
"collapse": "Plegar capítulo {{number}}",
"expand": "Desplegar capítulo {{number}}",
@ -515,13 +520,24 @@
"empty_title": "Este capítulo no tiene detalles",
"empty": "Añade una fila, artículos o un bloque de texto, o arrastra detalles aquí.",
"select_details": "Seleccionar los detalles del capítulo {{number}}",
"show_amounts": "Mostrar importes de los detalles en el PDF",
"show_amounts": "Importes por línea en 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_chapter_title": "Eliminar capítulo",
"delete_chapter_description": "Conceptos afectados: {{details}}. Elige si quieres conservarlos o eliminarlos.",
"delete_details_description": "Conceptos seleccionados: {{details}}. Se eliminarán solo estos conceptos; sus capítulos se conservarán.",
"keep_details_title": "Conservar conceptos",
"choose_destination": "Selecciona otro capítulo",
"transfer_description": "Se quitará el capítulo. Sus conceptos se trasladarán al destino que elijas y conservarán sus importes.",
"transfer_action": "Trasladar conceptos y quitar capítulo",
"keep_ungroup_description": "Es el último capítulo. Al quitarlo, sus conceptos quedarán sin agrupar y conservarán sus importes.",
"keep_ungroup_action": "Quitar capítulo y conservar conceptos",
"delete_all_title": "Eliminar capítulo y conceptos",
"delete_all_description": "Se eliminarán de la cotización el capítulo y todos sus conceptos.",
"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",
@ -575,7 +591,7 @@
"desc": "Nombre o etiqueta que se mostrará junto al impuesto"
},
"default_show_line_discount_column": {
"label": "Mostrar columna descuento por defecto",
"label": "Ver columna descuento por defecto",
"desc": "Los nuevos presupuestos mostrarán inicialmente la columna de descuento en las líneas. Podrás cambiar esta opción posteriormente en cada presupuesto."
},
"default_legal_terms": {
@ -642,4 +658,4 @@
"go_home": "Ir al inicio"
}
}
}
}

View File

@ -499,6 +499,10 @@
}
}
},
"empty_details": {
"title": "Ce devis ne contient encore aucune ligne",
"description": "Ajoutez une ligne vide, choisissez des articles du catalogue ou insérez un bloc de texte."
},
"chapters": {
"add": "Ajouter un chapitre",
"create_description": "La création du premier chapitre regroupe tous les détails existants.",
@ -506,6 +510,7 @@
"title": "Titre du chapitre {{number}}",
"title_required": "La description du chapitre est obligatoire.",
"select": "Sélectionner le chapitre {{number}}",
"folder_help": "Faites glisser pour déplacer tout le chapitre. Clic ou Entrée pour replier/déplier ; Espace pour déplacer au clavier.",
"move": "Déplacer le chapitre {{number}}",
"collapse": "Replier le chapitre {{number}}",
"expand": "Déplier le chapitre {{number}}",
@ -515,13 +520,24 @@
"empty_title": "Ce chapitre ne contient aucun détail",
"empty": "Ajoutez une ligne, des articles ou un bloc de texte, ou glissez des détails ici.",
"select_details": "Sélectionner les détails du chapitre {{number}}",
"show_amounts": "Afficher les montants des détails dans le PDF",
"show_amounts": "Montants par ligne en 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_chapter_title": "Supprimer le chapitre",
"delete_chapter_description": "Lignes concernées : {{details}}. Choisissez de les conserver ou de les supprimer.",
"delete_details_description": "Lignes sélectionnées : {{details}}. Seules ces lignes seront supprimées ; leurs chapitres seront conservés.",
"keep_details_title": "Conserver les lignes",
"choose_destination": "Sélectionnez un autre chapitre",
"transfer_description": "Le chapitre sera retiré. Ses lignes seront déplacées vers la destination choisie et conserveront leurs montants.",
"transfer_action": "Déplacer les lignes et retirer le chapitre",
"keep_ungroup_description": "C’est le dernier chapitre. En le retirant, ses lignes ne seront plus regroupées et conserveront leurs montants.",
"keep_ungroup_action": "Retirer le chapitre et conserver les lignes",
"delete_all_title": "Supprimer le chapitre et ses lignes",
"delete_all_description": "Le chapitre et toutes ses lignes seront supprimés du devis.",
"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",

View File

@ -408,3 +408,49 @@ Inconsistencias observadas, sin cambios fuera del alcance:
- `QuoteChaptersDataTable.tsx` ajusta el colspan y espaciado del título. `QuoteChapterEditor.test.tsx` adapta las comprobaciones de estructura y badge a las dos celdas del título. Se mantienen orden de columnas de los detalles y márgenes exteriores de 20 px.
- Validación: cinco casos existentes de alineación, orden y numeración (ejecutados también en el fixture temporal), ESLint sin errores, compilación Tailwind en `/tmp` y `git diff --check`. Chromium local mide diferencia de 0 px entre inicio del texto del título y lado izquierdo del icono de concepto, a 1440 y 1024 px, manteniendo márgenes de carpeta/papelera de 20 px. Fixture temporal retirado; muestra `/tmp/uecko-chapter-title.png`.
- No se ejecutan TypeScript, PDF, base de datos, build de distribución ni E2E autenticado: es un cambio local de disposición, sin cambios de tipos o datos. Persiste el aviso previo de Browserslist desactualizado.
### Ajuste: carpeta como asa de arrastre del capítulo
- `QuoteChaptersDataTable.tsx` vincula `setActivatorNodeRef`, atributos y listeners de `useSortable` al botón Folder/FolderOpen. Se elimina el asa independiente de la derecha. La carpeta conserva badge, márgenes, clic de plegado y estado accesible `aria-expanded`.
- El puntero muestra `grab`/`grabbing` y una ayuda nativa (`title`) explica que arrastra todo el capítulo y que el clic pliega/despliega. La misma ayuda se expone con `aria-description` y se traduce en `es/en/ca/fr`.
- Con teclado, Espacio inicia el arrastre y las flechas eligen destino; Entrar confirma el movimiento. Fuera de un arrastre, Entrar conserva el clic nativo de plegado. El click no cambia el plegado mientras se arrastra; el sensor real suprime el clic tras completar un movimiento con puntero.
- `QuoteChapterEditor.test.tsx` adapta las comprobaciones anteriores y verifica activador/ref, listeners de puntero, separación de Enter/Espacio, ayuda y conservación de datos al plegar. Validación: 27 pruebas de editor y cinco comprobaciones de traducciones; TypeScript sin emisión; ESLint acotado sin errores; `git diff --check`.
- Chromium ejecuta el componente, hook y sensores reales desde un bundle temporal en `/tmp`, con formulario local y sin API. Comprueba clic, reordenación mediante carpeta de capítulos abiertos/cerrados conservando contenido y plegado, y movimiento/plegado con teclado. Artefactos `/tmp/uecko-folder-drag-*` y `/tmp/uecko-folder-drag.png`; no se añaden dependencias ni fixtures al repositorio.
- No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado: cambia únicamente la interacción del editor. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
### Ajuste: eliminar columna vacía a la izquierda de los conceptos
- Se elimina la columna estructural `chapter_tree` y sus celdas vacías de los detalles, cabeceras, subtotales y capítulos vacíos. Las líneas empiezan directamente por arrastre y selección, igual que en una cotización sin capítulos.
- El título usa una única celda que abarca todas las columnas; carpeta, descripción y acciones se disponen dentro de ella con Tailwind inline. Conserva posición de la carpeta, badge, márgenes exteriores de 20 px y sus funciones de arrastre/plegado, sin reservar espacio en las filas inferiores.
- `QuoteChaptersDataTable.tsx` utiliza directamente las columnas de detalle y ajusta los colspan. `QuoteChapterEditor.test.tsx` adapta las comprobaciones de alineación, orden y badge a la nueva estructura. Validación: 27 pruebas de editor, TypeScript sin emisión, ESLint sin errores, Tailwind hacia `/tmp` y `git diff --check`.
- Chromium sobre HTML del harness confirma ausencia de columnas extra, arrastre a 8 px del inicio de la fila, checkbox en segunda columna y coincidencia de columnas/span de título, cabecera, detalles, subtotal y estado vacío, a 1440 y 1024 px. Carpeta/papelera mantienen márgenes de 20 px. Muestra `/tmp/uecko-chapter-gutter.png`; fixture temporal retirado.
- No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado por ser un cambio de disposición del editor. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
### Ajuste: contorno gris del capítulo completo
- `QuoteChaptersDataTable.tsx` añade bordes laterales grises de 1 px a las celdas exteriores de título, cabeceras, conceptos, estado vacío y subtotal. Junto con los bordes superior/inferior existentes forman un único contorno por capítulo, sin líneas verticales entre columnas ni bordes en la separación entre capítulos.
- Se conserva el resaltado rojo del conjunto detalle/acciones activo. El contorno sigue visible en capítulos vacíos y plegados; no se añaden columnas ni se cambian interacciones.
- Validación: cuatro casos existentes de alineación/orden y un fixture visual temporal; ESLint sin errores; Tailwind compilado en `/tmp`; `git diff --check`. Chromium verifica bordes exteriores de 1 px grises, ausencia de bordes verticales interiores y separación sin contorno, en estado normal, activo y plegado. Muestras `/tmp/uecko-chapter-border*.png`; fixture temporal retirado.
- No se ejecutan TypeScript, PDF, base de datos, build de distribución ni E2E autenticado: el cambio añade únicamente clases de estilo. Persiste el aviso previo de Browserslist desactualizado.
### Ajuste: etiqueta breve de importes PDF
- Se sustituye la etiqueta por «Importes por línea en PDF» y se actualizan sus traducciones en `es/en/ca/fr`. Activado sigue mostrando los importes de cada detalle en el PDF; desactivado mantiene únicamente los subtotales de capítulo. No se invierte ni cambia la preferencia guardada.
- Se adapta la prueba de preferencia del editor. Validación acotada: prueba de persistencia PDF y cinco comprobaciones de traducciones; `git diff --check`. No se ejecutan builds, TypeScript, base de datos, generación de PDF ni E2E por ser un cambio de texto.
### Ajuste: diálogo de eliminación con alternativas separadas
- `QuoteChaptersDataTable.tsx` distingue eliminar un capítulo de eliminar únicamente conceptos seleccionados. El diálogo de capítulo presenta dos secciones con nombre accesible, explicación propia y botón independiente: «Conservar conceptos» con traslado al destino elegido y «Eliminar capítulo y conceptos» con tratamiento destructivo.
- El selector de destino pertenece únicamente al bloque de conservación; el botón «Trasladar conceptos y quitar capítulo» permanece deshabilitado hasta elegir destino. «Cancelar» está separado en el pie. Si se elimina el último capítulo, la alternativa de conservación explica que los conceptos quedarán sin agrupar y conserva sus importes.
- El diálogo tiene ancho adaptable, botones con texto ajustable y desplazamiento vertical si falta altura. Se conservan la lógica de eliminación/traslado y la eliminación inmediata de capítulos vacíos. Nuevos textos en `es/en/ca/fr`; las cantidades se presentan como «Conceptos afectados: N» para evitar concordancias incorrectas con uno.
- `QuoteChapterEditor.test.tsx` verifica separación de acciones, destino obligatorio, transferencia conservando valores y eliminación exclusiva de conceptos sin borrar sus capítulos; se adaptan los textos de las confirmaciones existentes. Validación: 10 pruebas acotadas (cinco del editor y cinco de traducciones), TypeScript sin emisión, ESLint sin errores, Tailwind hacia `/tmp` y `git diff --check`.
- Chromium ejecuta componente, formulario y selectores reales en un bundle local sin API a 1440 y 375 px. Verifica separación de bloques, ausencia de desbordamiento horizontal, desactivación sin destino, traslado preservando conceptos/precios y conservación sin agrupar al quitar el último capítulo. Muestras `/tmp/uecko-delete-chapter-*.png`; no se incorporan fixtures ni dependencias al repositorio.
- No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado: cambia la presentación de decisiones existentes en el editor. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
### Ampliación: estado vacío para cotizaciones sin líneas ni capítulos
- `QuoteItemsSortableDataTable.tsx` muestra `Empty` cuando no hay detalles, en lugar de cabeceras y tabla sin contenido. Mensaje, icono neutro y botones para añadir fila vacía, artículo del catálogo o bloque de texto; usa las mismas acciones de tabla que la barra existente. El pie de botones se oculta mientras está vacío para no duplicar las acciones del propio `Empty`.
- Al insertar el primer concepto aparece la tabla normal, sin crear capítulos. El estado también se aplica cuando una cotización vuelve a quedarse sin conceptos. Las cotizaciones con capítulos conservan sus estados vacíos propios. Nuevos textos `quotes.empty_details` en `es/en/ca/fr`.
- `QuoteChapterEditor.test.tsx` incorpora un formulario inicialmente vacío y tres pruebas de alta desde `Empty`, incluyendo los dos selectores y conservación del modo sin capítulos. Validación: tres pruebas nuevas y cinco de traducciones, tres casos existentes de tabla libre/orden de columnas, TypeScript sin emisión, ESLint acotado sin errores (cuatro avisos `any` previos), Tailwind hacia `/tmp` y `git diff --check`.
- Chromium sobre el HTML del harness revisa a 1440 y 375 px la ausencia de tabla vacía, los tres botones, ajuste al ancho y ausencia de desbordamiento. Muestras `/tmp/uecko-new-empty-*.png`; fixture temporal retirado. No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado al limitarse al editor.