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:
parent
3b660bf27e
commit
e332fcba52
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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ó",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user