feat: enhance chapter and detail row interactions with improved animations and visibility controls
- Adjusted drag handle alignment in QuoteChaptersDataTable for consistent width and positioning. - Implemented height and opacity transitions in useChapterCollapse for smoother chapter collapsing. - Updated QuoteItemsSortableTableRow to identify active rows with data attributes and apply consistent styling. - Refined detail row selection to limit checkboxes to detail rows only, improving user experience. - Enhanced visibility of controls on hover and focus for both chapter titles and detail rows. - Improved spacing and alignment for chapter titles and associated controls, ensuring a cohesive layout. - Added tests to validate new behaviors and interactions, ensuring functionality remains intact.
This commit is contained in:
parent
bc34a073ad
commit
3b660bf27e
@ -11,13 +11,13 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Label,
|
|
||||||
Empty,
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
EmptyHeader,
|
EmptyHeader,
|
||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
EmptyDescription,
|
Label,
|
||||||
EmptyContent,
|
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
@ -29,12 +29,12 @@ import {
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/ui/table";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
|
||||||
pointerWithin,
|
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
KeyboardCoordinateGetter,
|
KeyboardCoordinateGetter,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
pointerWithin,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
@ -48,37 +48,31 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import { CopyIcon, FolderIcon, FolderOpenIcon, GripVerticalIcon, Trash2Icon } from "lucide-react";
|
||||||
CopyIcon,
|
|
||||||
FolderIcon,
|
|
||||||
FolderOpenIcon,
|
|
||||||
GripVerticalIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Fragment, ReactNode, useMemo, useState } from "react";
|
import { Fragment, ReactNode, useMemo, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
chapterSubtotal,
|
|
||||||
duplicateChapterSelection,
|
|
||||||
EditorChapter,
|
EditorChapter,
|
||||||
EditorItem,
|
EditorItem,
|
||||||
|
chapterSubtotal,
|
||||||
|
duplicateChapterSelection,
|
||||||
insertChapterItem,
|
insertChapterItem,
|
||||||
moveChapterItems,
|
moveChapterItems,
|
||||||
moveChapters,
|
moveChapters,
|
||||||
removeChapterSelection,
|
removeChapterSelection,
|
||||||
ungroupChapters,
|
ungroupChapters,
|
||||||
} from "../chapterEditor";
|
} from "../chapterEditor";
|
||||||
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
|
||||||
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
import { useChapterCollapse } from "../hooks/useChapterCollapse";
|
||||||
|
import { ChapterEditorController } from "../hooks/useChapterEditor";
|
||||||
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
|
||||||
|
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
||||||
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
|
||||||
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
|
||||||
import { RowIdData } from "./QuoteItemsSortableDataTable";
|
|
||||||
|
|
||||||
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
|
||||||
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
|
||||||
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
import { AppendBlockRowButton } from "./AppendBlockRowButton";
|
||||||
|
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
|
||||||
|
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
|
||||||
|
|
||||||
type Selection = { chapters: string[]; indexes: number[] };
|
type Selection = { chapters: string[]; indexes: number[] };
|
||||||
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
|
||||||
@ -110,7 +104,11 @@ const keyboardCoordinates: KeyboardCoordinateGetter = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ChapterContent({ children }: { children: ReactNode }) {
|
function ChapterContent({ children }: { children: ReactNode }) {
|
||||||
return <div data-chapter-content className='min-w-0'>{children}</div>;
|
return (
|
||||||
|
<div data-chapter-content className='min-w-0'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve the expand column in detail rows so all controls stay aligned.
|
// Reserve the expand column in detail rows so all controls stay aligned.
|
||||||
@ -118,7 +116,15 @@ function ChapterSpacerCell() {
|
|||||||
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
|
return <TableCell className='p-0' data-chapter-column='chapter_tree' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArticle, onAddBlock }: {
|
function EmptyChapterTarget({
|
||||||
|
id,
|
||||||
|
columns,
|
||||||
|
hidden,
|
||||||
|
collapsed,
|
||||||
|
onAddRow,
|
||||||
|
onAddArticle,
|
||||||
|
onAddBlock,
|
||||||
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
columns: number;
|
columns: number;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
@ -147,7 +153,7 @@ function EmptyChapterTarget({ id, columns, hidden, collapsed, onAddRow, onAddArt
|
|||||||
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
|
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
|
||||||
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
|
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
|
<EmptyContent className='flex-row flex-wrap justify-center gap-2 max-w-none'>
|
||||||
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
|
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
|
||||||
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
|
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
|
||||||
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
|
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
|
||||||
@ -164,24 +170,18 @@ function ChapterGroup({
|
|||||||
index,
|
index,
|
||||||
columns,
|
columns,
|
||||||
children,
|
children,
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onActivate,
|
onActivate,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRemove,
|
onRemove,
|
||||||
mixed,
|
|
||||||
subtotal,
|
subtotal,
|
||||||
}: {
|
}: {
|
||||||
chapter: EditorChapter;
|
chapter: EditorChapter;
|
||||||
index: number;
|
index: number;
|
||||||
columns: number;
|
columns: number;
|
||||||
children: (collapsed: boolean, hidden: boolean) => ReactNode;
|
children: (collapsed: boolean, hidden: boolean) => ReactNode;
|
||||||
selected: boolean;
|
|
||||||
onSelect: (selected: boolean) => void;
|
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
mixed: boolean;
|
|
||||||
subtotal: string;
|
subtotal: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -195,14 +195,23 @@ function ChapterGroup({
|
|||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id: `chapter:${chapter.id}`, disabled: mixed });
|
} = useSortable({ id: `chapter:${chapter.id}` });
|
||||||
return (
|
return (
|
||||||
<TableBody
|
<TableBody
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
setNodeRef(node);
|
setNodeRef(node);
|
||||||
ref.current = node;
|
ref.current = node;
|
||||||
}}
|
}}
|
||||||
className='bg-background [&>tr:not([data-chapter-gap])>td]:border-b [&>tr:not([data-chapter-gap])>td]:border-b-border'
|
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[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
|
||||||
|
[&>tr[data-active-item]>td:last-child]:border-r
|
||||||
|
[&>tr[data-active-item=detail]>td]:border-t
|
||||||
|
[&>tr[data-active-item=detail]>td]:border-b-0
|
||||||
|
[&>tr[data-active-item=actions]>td]:border-b`}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@ -215,16 +224,16 @@ function ChapterGroup({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
<TableRow
|
<TableRow
|
||||||
className='bg-muted hover:bg-muted [&>td]:border-t [&>td]:py-3 [&>td:first-child]:rounded-tl-md [&>td:last-child]:rounded-tr-md'
|
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}
|
data-chapter-id={chapter.id}
|
||||||
>
|
>
|
||||||
<TableCell className='px-1'>
|
<TableCell className='pl-3.5 pr-1'>
|
||||||
<div className='relative h-8 w-8'>
|
<div className='relative w-8 h-8'>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='icon'
|
size='icon'
|
||||||
className='h-8 w-8 text-muted-foreground'
|
className='w-8 h-8 text-muted-foreground'
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
|
aria-label={t(collapsed ? "quotes.chapters.expand" : "quotes.chapters.collapse", {
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
@ -235,9 +244,9 @@ function ChapterGroup({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<FolderIcon className='h-5 w-5' aria-hidden='true' />
|
<FolderIcon className='w-5 h-5' aria-hidden='true' />
|
||||||
) : (
|
) : (
|
||||||
<FolderOpenIcon className='h-5 w-5' aria-hidden='true' />
|
<FolderOpenIcon className='w-5 h-5' aria-hidden='true' />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Badge
|
<Badge
|
||||||
@ -249,30 +258,7 @@ function ChapterGroup({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='px-2'>
|
<TableCell colSpan={columns - 1} className='px-2'>
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
size='icon'
|
|
||||||
disabled={mixed}
|
|
||||||
ref={setActivatorNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className='h-8 w-4 touch-none cursor-grab'
|
|
||||||
onFocus={onActivate}
|
|
||||||
aria-label={t("quotes.chapters.move", { number: index + 1 })}
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className='h-4 w-4' />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className='px-2'>
|
|
||||||
<Checkbox
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={(value) => onSelect(Boolean(value))}
|
|
||||||
aria-label={t("quotes.chapters.select", { number: index + 1 })}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell colSpan={columns - 3} className='px-2'>
|
|
||||||
<div className='flex items-start gap-2'>
|
<div className='flex items-start gap-2'>
|
||||||
<Controller
|
<Controller
|
||||||
key={`${chapter.id}:${index}`}
|
key={`${chapter.id}:${index}`}
|
||||||
@ -283,7 +269,7 @@ function ChapterGroup({
|
|||||||
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
|
Boolean(value?.trim()) || t("quotes.chapters.title_required"),
|
||||||
}}
|
}}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='-ml-px flex-1 min-w-0'>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
@ -291,7 +277,7 @@ function ChapterGroup({
|
|||||||
onFocus={onActivate}
|
onFocus={onActivate}
|
||||||
aria-label={t("quotes.chapters.title", { number: index + 1 })}
|
aria-label={t("quotes.chapters.title", { number: index + 1 })}
|
||||||
aria-invalid={Boolean(fieldState.error)}
|
aria-invalid={Boolean(fieldState.error)}
|
||||||
className='min-h-10 border-transparent bg-transparent font-semibold shadow-none hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
|
className='px-0 font-semibold bg-transparent border-transparent shadow-none min-h-10 hover:border-input hover:bg-background focus-visible:border-input focus-visible:bg-background'
|
||||||
/>
|
/>
|
||||||
{fieldState.error && (
|
{fieldState.error && (
|
||||||
<p role='alert' className='text-sm text-destructive'>
|
<p role='alert' className='text-sm text-destructive'>
|
||||||
@ -301,23 +287,38 @@ function ChapterGroup({
|
|||||||
</div>
|
</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
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='icon'
|
size='icon'
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
|
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||||
aria-label={t("quotes.chapters.duplicate", { number: index + 1 })}
|
aria-label={t("quotes.chapters.duplicate", { number: index + 1 })}
|
||||||
>
|
>
|
||||||
<CopyIcon className='h-4 w-4' />
|
<CopyIcon className='w-4 h-4' />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='icon'
|
size='icon'
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
|
className='opacity-0 transition-opacity group-hover/chapter:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||||
aria-label={t("quotes.chapters.remove", { number: index + 1 })}
|
aria-label={t("quotes.chapters.remove", { number: index + 1 })}
|
||||||
>
|
>
|
||||||
<Trash2Icon className='h-4 w-4' />
|
<Trash2Icon className='w-4 h-4' />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -331,7 +332,7 @@ function ChapterGroup({
|
|||||||
<TableCell colSpan={columns - 2} className='pl-4 text-sm text-muted-foreground'>
|
<TableCell colSpan={columns - 2} className='pl-4 text-sm text-muted-foreground'>
|
||||||
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
|
{t("quotes.chapters.subtotal", { number: index + 1, description: chapter.description })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='whitespace-nowrap text-right text-base'>{subtotal}</TableCell>
|
<TableCell className='text-base text-right whitespace-nowrap'>{subtotal}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
);
|
);
|
||||||
@ -365,19 +366,14 @@ export function QuoteChaptersDataTable({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { watch, setValue } = useFormContext();
|
const { watch, setValue } = useFormContext();
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [chapterSelection, setChapterSelection] = useState<string[]>([]);
|
|
||||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Selection | null>(null);
|
const [deleting, setDeleting] = useState<Selection | null>(null);
|
||||||
const [ungrouping, setUngrouping] = useState(false);
|
const [ungrouping, setUngrouping] = useState(false);
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const { chapters, items } = controller;
|
const { chapters, items } = controller;
|
||||||
const selectedIndexes = data.flatMap((row, index) => (rowSelection[row.id] ? [index] : []));
|
const selectedIndexes = data.flatMap((row, index) => (rowSelection[row.id] ? [index] : []));
|
||||||
const mixed =
|
|
||||||
chapterSelection.length > 0 &&
|
|
||||||
selectedIndexes.some((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""));
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
setChapterSelection([]);
|
|
||||||
setActiveRow(null);
|
setActiveRow(null);
|
||||||
};
|
};
|
||||||
const apply = (state: Parameters<typeof controller.apply>[0]) => {
|
const apply = (state: Parameters<typeof controller.apply>[0]) => {
|
||||||
@ -427,11 +423,8 @@ export function QuoteChaptersDataTable({
|
|||||||
apply(next);
|
apply(next);
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
};
|
};
|
||||||
const selection = { chapters: chapterSelection, indexes: selectedIndexes };
|
const selection: Selection = { chapters: [], indexes: selectedIndexes };
|
||||||
const selectedCount =
|
const selectedCount = selectedIndexes.length;
|
||||||
chapterSelection.length +
|
|
||||||
selectedIndexes.filter((index) => !chapterSelection.includes(items[index]?.chapter_id ?? ""))
|
|
||||||
.length;
|
|
||||||
const insert = (index: number, after = false) => {
|
const insert = (index: number, after = false) => {
|
||||||
const state = controller.read();
|
const state = controller.read();
|
||||||
apply(
|
apply(
|
||||||
@ -444,17 +437,20 @@ export function QuoteChaptersDataTable({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
|
const chapterColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
id: "chapter_tree",
|
id: "chapter_tree",
|
||||||
header: () => null,
|
header: () => null,
|
||||||
cell: () => null,
|
cell: () => null,
|
||||||
size: 44,
|
size: 56,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
...columns,
|
...columns,
|
||||||
], [columns]);
|
],
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns: chapterColumns,
|
columns: chapterColumns,
|
||||||
@ -494,17 +490,16 @@ export function QuoteChaptersDataTable({
|
|||||||
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates })
|
useSensor(KeyboardSensor, { coordinateGetter: keyboardCoordinates })
|
||||||
);
|
);
|
||||||
const dragEnd = ({ active, over }: DragEndEvent) => {
|
const dragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id || mixed) return;
|
if (!over || active.id === over.id) return;
|
||||||
const state = controller.read();
|
const state = controller.read();
|
||||||
if (isChapter(active.id)) {
|
if (isChapter(active.id)) {
|
||||||
if (!isChapter(over.id)) return;
|
if (!isChapter(over.id)) return;
|
||||||
const from = String(active.id).slice(8);
|
const from = String(active.id).slice(8);
|
||||||
const to = String(over.id).slice(8);
|
const to = String(over.id).slice(8);
|
||||||
const moving = chapterSelection.includes(from) ? chapterSelection : [from];
|
|
||||||
apply(
|
apply(
|
||||||
moveChapters(
|
moveChapters(
|
||||||
state,
|
state,
|
||||||
moving,
|
[from],
|
||||||
to,
|
to,
|
||||||
state.chapters.findIndex((chapter) => chapter.id === from) <
|
state.chapters.findIndex((chapter) => chapter.id === from) <
|
||||||
state.chapters.findIndex((chapter) => chapter.id === to)
|
state.chapters.findIndex((chapter) => chapter.id === to)
|
||||||
@ -543,12 +538,7 @@ export function QuoteChaptersDataTable({
|
|||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragEnd={dragEnd}
|
onDragEnd={dragEnd}
|
||||||
onDragStart={({ active }) => {
|
onDragStart={({ active }) => {
|
||||||
if (
|
if (isChapter(active.id) || !rowSelection[active.id]) clear();
|
||||||
isChapter(active.id)
|
|
||||||
? !chapterSelection.includes(String(active.id).slice(8))
|
|
||||||
: !rowSelection[active.id]
|
|
||||||
)
|
|
||||||
clear();
|
|
||||||
}}
|
}}
|
||||||
collisionDetection={(args) => {
|
collisionDetection={(args) => {
|
||||||
const valid = {
|
const valid = {
|
||||||
@ -587,16 +577,19 @@ export function QuoteChaptersDataTable({
|
|||||||
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
|
<Button type='button' variant='ghost' size='sm' onClick={onAddChapter}>
|
||||||
{t("quotes.chapters.add")}
|
{t("quotes.chapters.add")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='button' variant='ghost' size='sm' onClick={() => setUngrouping(true)}>
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setUngrouping(true)}
|
||||||
|
>
|
||||||
{t("quotes.chapters.ungroup")}
|
{t("quotes.chapters.ungroup")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
viewControls={
|
viewControls={
|
||||||
<div className='flex min-h-9 items-center gap-2'>
|
<div className='flex items-center gap-2 min-h-9'>
|
||||||
<Label htmlFor='chapter-line-amounts'>
|
<Label htmlFor='chapter-line-amounts'>{t("quotes.chapters.show_amounts")}</Label>
|
||||||
{t("quotes.chapters.show_amounts")}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
<Switch
|
||||||
id='chapter-line-amounts'
|
id='chapter-line-amounts'
|
||||||
checked={watch("show_chapter_line_amounts") ?? true}
|
checked={watch("show_chapter_line_amounts") ?? true}
|
||||||
@ -607,14 +600,9 @@ export function QuoteChaptersDataTable({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{mixed && (
|
|
||||||
<p role='status' className='text-sm'>
|
|
||||||
{t("quotes.chapters.mixed_selection")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table className='table-fixed border-separate border-spacing-0'>
|
<Table className='border-separate table-fixed border-spacing-0'>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
{table.getVisibleLeafColumns().map((column) => (
|
{table.getVisibleLeafColumns().map((column) => (
|
||||||
<col key={column.id} style={{ width: column.getSize() }} />
|
<col key={column.id} style={{ width: column.getSize() }} />
|
||||||
@ -634,15 +622,6 @@ export function QuoteChaptersDataTable({
|
|||||||
chapter={chapter}
|
chapter={chapter}
|
||||||
index={chapterIndex}
|
index={chapterIndex}
|
||||||
columns={countColumns}
|
columns={countColumns}
|
||||||
mixed={mixed}
|
|
||||||
selected={chapterSelection.includes(chapter.id)}
|
|
||||||
onSelect={(value) =>
|
|
||||||
setChapterSelection((previous) =>
|
|
||||||
value
|
|
||||||
? [...previous, chapter.id]
|
|
||||||
: previous.filter((id) => id !== chapter.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onActivate={() => controller.setActiveId(chapter.id)}
|
onActivate={() => controller.setActiveId(chapter.id)}
|
||||||
onDuplicate={() => duplicate({ chapters: [chapter.id], indexes: [] })}
|
onDuplicate={() => duplicate({ chapters: [chapter.id], indexes: [] })}
|
||||||
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
|
onRemove={() => remove({ chapters: [chapter.id], indexes: [] })}
|
||||||
@ -650,7 +629,8 @@ export function QuoteChaptersDataTable({
|
|||||||
>
|
>
|
||||||
{(collapsed, hidden) => (
|
{(collapsed, hidden) => (
|
||||||
<>
|
<>
|
||||||
{rows.length > 0 && table.getHeaderGroups().map((group) => (
|
{rows.length > 0 &&
|
||||||
|
table.getHeaderGroups().map((group) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={group.id}
|
key={group.id}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
@ -667,7 +647,8 @@ export function QuoteChaptersDataTable({
|
|||||||
})}
|
})}
|
||||||
checked={
|
checked={
|
||||||
rows.every((row) => row.getIsSelected()) ||
|
rows.every((row) => row.getIsSelected()) ||
|
||||||
(rows.some((row) => row.getIsSelected()) && "indeterminate")
|
(rows.some((row) => row.getIsSelected()) &&
|
||||||
|
"indeterminate")
|
||||||
}
|
}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
setRowSelection((previous) => {
|
setRowSelection((previous) => {
|
||||||
@ -681,7 +662,10 @@ export function QuoteChaptersDataTable({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : header.isPlaceholder ? null : (
|
) : header.isPlaceholder ? null : (
|
||||||
flexRender(header.column.columnDef.header, header.getContext())
|
flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</ChapterContent>
|
</ChapterContent>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@ -697,7 +681,7 @@ export function QuoteChaptersDataTable({
|
|||||||
<QuoteItemsSortableTableRow
|
<QuoteItemsSortableTableRow
|
||||||
id={row.id}
|
id={row.id}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={collapsed || mixed || chapterSelection.length > 0}
|
disabled={collapsed}
|
||||||
isActive={activeRow === row.id}
|
isActive={activeRow === row.id}
|
||||||
onActivate={(event) => {
|
onActivate={(event) => {
|
||||||
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
|
if ((event.target as HTMLElement).closest('[role="checkbox"]'))
|
||||||
@ -706,23 +690,35 @@ export function QuoteChaptersDataTable({
|
|||||||
controller.setActiveId(chapter.id);
|
controller.setActiveId(chapter.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => cell.column.id === "chapter_tree" ? (
|
{row.getVisibleCells().map((cell) =>
|
||||||
|
cell.column.id === "chapter_tree" ? (
|
||||||
<ChapterSpacerCell key={cell.id} />
|
<ChapterSpacerCell key={cell.id} />
|
||||||
) : (
|
) : (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={cell.column.id === "description" ? "py-2 pl-4 pr-2 align-top" : "px-2 py-2 align-top"}
|
className={
|
||||||
|
cell.column.id === "description"
|
||||||
|
? "py-2 pl-4 pr-2 align-top"
|
||||||
|
: "px-2 py-2 align-top"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ChapterContent>
|
<ChapterContent>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
</ChapterContent>
|
</ChapterContent>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</QuoteItemsSortableTableRow>
|
</QuoteItemsSortableTableRow>
|
||||||
{activeRow === row.id && (
|
{activeRow === row.id && (
|
||||||
<TableRow hidden={hidden}>
|
<TableRow
|
||||||
<ChapterSpacerCell />
|
hidden={hidden}
|
||||||
<TableCell colSpan={countColumns - 1}>
|
data-active-item='actions'
|
||||||
|
className='bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5'
|
||||||
|
>
|
||||||
|
<TableCell colSpan={countColumns} className='px-2 py-2'>
|
||||||
<ChapterContent>
|
<ChapterContent>
|
||||||
<QuoteItemsInlineRowActions
|
<QuoteItemsInlineRowActions
|
||||||
confirmDelete={false}
|
confirmDelete={false}
|
||||||
@ -744,7 +740,9 @@ export function QuoteChaptersDataTable({
|
|||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onAddRow={() => {
|
onAddRow={() => {
|
||||||
controller.setActiveId(chapter.id);
|
controller.setActiveId(chapter.id);
|
||||||
apply(insertChapterItem(controller.read(), defaultItem, chapter.id));
|
apply(
|
||||||
|
insertChapterItem(controller.read(), defaultItem, chapter.id)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onAddArticle={() => {
|
onAddArticle={() => {
|
||||||
controller.setActiveId(chapter.id);
|
controller.setActiveId(chapter.id);
|
||||||
@ -793,7 +791,8 @@ export function QuoteChaptersDataTable({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{destinations.map((chapter) => (
|
{destinations.map((chapter) => (
|
||||||
<SelectItem key={chapter.id} value={chapter.id}>
|
<SelectItem key={chapter.id} value={chapter.id}>
|
||||||
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}. {chapter.description}
|
{chapters.findIndex((entry) => entry.id === chapter.id) + 1}.{" "}
|
||||||
|
{chapter.description}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export const QuoteItemsSortableDragHandleCell = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
isDragging ? "cursor-grabbing" : "cursor-grab",
|
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||||
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground touch-none",
|
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground touch-none",
|
||||||
|
"transition-opacity motion-reduce:transition-none group-hover/detail:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100",
|
||||||
|
isDragging ? "opacity-100" : "opacity-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
|
|||||||
@ -84,12 +84,13 @@ export function QuoteItemsSortableTableRow({
|
|||||||
key={id}
|
key={id}
|
||||||
id={String(id)}
|
id={String(id)}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
|
data-active-item={isActive ? "detail" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
isDragging ? "opacity-60" : "opacity-100",
|
isDragging ? "opacity-60" : "opacity-100",
|
||||||
isActive
|
isActive
|
||||||
? "border border-destructive/30 border-b-0 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5"
|
? "border border-destructive/30 border-b-0 border-l-4 border-l-destructive bg-destructive/5 hover:bg-destructive/5 focus-within:bg-destructive/5"
|
||||||
: "hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent",
|
: "hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent",
|
||||||
"m-0"
|
"group/detail m-0"
|
||||||
)}
|
)}
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
|
|||||||
@ -318,11 +318,9 @@ it("moves a whole chapter with its details, and details to an empty chapter", ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("duplicates a selected chapter once and can return to free mode preserving every detail", () => {
|
it("duplicates a chapter using its action and can return to free mode preserving every detail", () => {
|
||||||
render(<Harness grouped />);
|
render(<Harness grouped />);
|
||||||
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
|
fireEvent.click(screen.getByLabelText("Duplicar capítulo 1"));
|
||||||
fireEvent.click(screen.getByLabelText("Seleccionar fila"));
|
|
||||||
fireEvent.click(screen.getByText("Duplicar"));
|
|
||||||
expect(snapshot().chapters).toHaveLength(3);
|
expect(snapshot().chapters).toHaveLength(3);
|
||||||
expect(snapshot().items).toHaveLength(2);
|
expect(snapshot().items).toHaveLength(2);
|
||||||
expect(snapshot().items[1].chapter_id).not.toBe(chapterA);
|
expect(snapshot().items[1].chapter_id).not.toBe(chapterA);
|
||||||
@ -410,22 +408,19 @@ it("deletes chapter content only after explicit confirmation", () => {
|
|||||||
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
|
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([chapterB]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables mixed chapter/detail dragging without disabling selection actions", () => {
|
it("selects only detail rows and keeps chapter controls available", () => {
|
||||||
render(<Harness grouped />);
|
render(<Harness grouped />);
|
||||||
fireEvent.focus(screen.getByLabelText("Título del capítulo 2"));
|
for (const heading of document.querySelectorAll("[data-chapter-id]")) {
|
||||||
fireEvent.click(screen.getByText("Añadir fila"));
|
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
|
||||||
fireEvent.click(screen.getByLabelText("Seleccionar capítulo 1"));
|
}
|
||||||
const row = screen.getByLabelText("items.1.description").closest("tr")!;
|
const row = screen.getByLabelText("items.0.description").closest("tr")!;
|
||||||
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
|
fireEvent.click(within(row).getByLabelText("Seleccionar fila"));
|
||||||
expect(
|
expect(within(row).getByLabelText("Seleccionar fila").getAttribute("aria-checked")).toBe("true");
|
||||||
screen.getByText("Para mover la selección, selecciona solo capítulos o solo detalles.")
|
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(false);
|
||||||
).toBeTruthy();
|
fireEvent.click(screen.getByText("Duplicar"));
|
||||||
expect((screen.getByLabelText("Mover capítulo 1") as HTMLButtonElement).disabled).toBe(true);
|
expect(snapshot().chapters).toHaveLength(2);
|
||||||
drop(`chapter:${chapterA}`, `chapter:${chapterB}`);
|
expect(snapshot().items).toHaveLength(2);
|
||||||
expect(snapshot().chapters.map((entry: { id: string }) => entry.id)).toEqual([
|
expect(snapshot().items.every((entry: { chapter_id: string }) => entry.chapter_id === chapterA)).toBe(true);
|
||||||
chapterA,
|
|
||||||
chapterB,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses valid keyboard destinations and rejects pointer drops outside chapter contents", () => {
|
it("uses valid keyboard destinations and rejects pointer drops outside chapter contents", () => {
|
||||||
@ -562,7 +557,7 @@ it.each([true, false])("orders expand, drag and select controls with the expand
|
|||||||
const heading = table.querySelector<HTMLTableRowElement>(`[data-chapter-id="${chapterA}"]`)!;
|
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("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("Mover capítulo 1").closest("td")).toBe(heading.cells[1]);
|
||||||
expect(screen.getByLabelText("Seleccionar capítulo 1").closest("td")).toBe(heading.cells[2]);
|
expect(heading.querySelector('[role="checkbox"]')).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -644,8 +639,8 @@ it("shows chapter numbers as badges on the folder controls, including after reor
|
|||||||
expect(badge.textContent).toBe(String(number));
|
expect(badge.textContent).toBe(String(number));
|
||||||
expect(toggle.querySelector("svg")).toBeTruthy();
|
expect(toggle.querySelector("svg")).toBeTruthy();
|
||||||
expect(toggle.getAttribute("aria-label")).toContain(String(number));
|
expect(toggle.getAttribute("aria-label")).toContain(String(number));
|
||||||
expect(heading.cells[3].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
|
expect(heading.cells[1].querySelector('svg.lucide-folder, svg.lucide-folder-open')).toBeNull();
|
||||||
expect(heading.cells[3].textContent).not.toMatch(/^\s*\d+/);
|
expect(heading.cells[1].textContent).not.toMatch(/^\s*\d+/);
|
||||||
};
|
};
|
||||||
assertBadge(chapterA, 1);
|
assertBadge(chapterA, 1);
|
||||||
assertBadge(chapterB, 2);
|
assertBadge(chapterB, 2);
|
||||||
|
|||||||
@ -12,14 +12,14 @@ import { useToast } from "@/ui/use-toast";
|
|||||||
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
|
import { CurrencyData, Language, Quantity, UnitPrice } from "@shared/contexts";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { addChapter, EditorItem } from "../../chapterEditor";
|
import { addChapter, EditorItem } from "../../chapterEditor";
|
||||||
import { useChapterEditor } from "../../hooks/useChapterEditor";
|
import { useChapterEditor } from "../../hooks/useChapterEditor";
|
||||||
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
|
|
||||||
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
|
|
||||||
import { useDetailColumns } from "../../hooks/useDetailColumns";
|
import { useDetailColumns } from "../../hooks/useDetailColumns";
|
||||||
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
|
import { CatalogPickerDataTable } from "../CatalogPickerDataTable";
|
||||||
|
import { QuoteChapterCreateDialog } from "../QuoteChapterCreateDialog";
|
||||||
|
import { QuoteChaptersDataTable } from "../QuoteChaptersDataTable";
|
||||||
import { QuoteItemsSortableDataTable, RowIdData } from "../QuoteItemsSortableDataTable";
|
import { QuoteItemsSortableDataTable, RowIdData } from "../QuoteItemsSortableDataTable";
|
||||||
import { BlocksPickerDialog } from "./BlocksPickerDialog/BlocksPickerDialog";
|
import { BlocksPickerDialog } from "./BlocksPickerDialog/BlocksPickerDialog";
|
||||||
import { CatalogPickerDialog } from "./CatalogPickerDialog";
|
import { CatalogPickerDialog } from "./CatalogPickerDialog";
|
||||||
@ -39,8 +39,8 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
const documentLanguageCode = watch("lang_code") || Language.DEFAULT_LANGUAGE_CODE;
|
const documentLanguageCode = watch("lang_code") || Language.DEFAULT_LANGUAGE_CODE;
|
||||||
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
|
const showLineDiscountColumn = watch("show_line_discount_column") ?? true;
|
||||||
const hasLineDiscounts = Boolean(
|
const hasLineDiscounts = Boolean(
|
||||||
getValues("items")?.some((item: { discount?: { amount?: number | null } }) =>
|
getValues("items")?.some(
|
||||||
Number(item.discount?.amount) > 0
|
(item: { discount?: { amount?: number | null } }) => Number(item.discount?.amount) > 0
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -56,12 +56,16 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
|
|
||||||
const chapterEditor = useChapterEditor(fieldActions.replace);
|
const chapterEditor = useChapterEditor(fieldActions.replace);
|
||||||
const [createChapterOpen, setCreateChapterOpen] = useState(false);
|
const [createChapterOpen, setCreateChapterOpen] = useState(false);
|
||||||
const appendPicked = useCallback((item: unknown) => {
|
const appendPicked = useCallback(
|
||||||
|
(item: unknown) => {
|
||||||
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
|
if (chapterEditor.chapters.length) chapterEditor.appendPickedItem(item as EditorItem);
|
||||||
else fieldActions.append(item);
|
else fieldActions.append(item);
|
||||||
}, [chapterEditor, fieldActions]);
|
},
|
||||||
|
[chapterEditor, fieldActions]
|
||||||
|
);
|
||||||
|
|
||||||
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(() => [
|
const detailColumns = useMemo<ColumnDef<RowIdData, unknown>[]>(
|
||||||
|
() => [
|
||||||
/*{
|
/*{
|
||||||
id: "row_id" as const,
|
id: "row_id" as const,
|
||||||
header: () => (
|
header: () => (
|
||||||
@ -95,17 +99,12 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
cell: ({ row: { index, original } }) => {
|
cell: ({ row: { index, original } }) => {
|
||||||
if (original?.id_article) {
|
if (original?.id_article) {
|
||||||
return (
|
return (
|
||||||
<div className='min-h-10 whitespace-pre-wrap px-3 py-2 text-sm'>
|
<div className='px-3 py-2 text-sm whitespace-pre-wrap min-h-10'>
|
||||||
{original.localized_description ?? original.description}
|
{original.localized_description ?? original.description}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <FormTextAreaField autoSize name={`items.${index}.description`} />;
|
||||||
<FormTextAreaField
|
|
||||||
autoSize
|
|
||||||
name={`items.${index}.description`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
minSize: 200,
|
minSize: 200,
|
||||||
size: 400,
|
size: 400,
|
||||||
@ -118,11 +117,7 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
),
|
),
|
||||||
cell: ({ row: { index } }) => {
|
cell: ({ row: { index } }) => {
|
||||||
return (
|
return (
|
||||||
<FormQuantityField
|
<FormQuantityField scale={0} className='text-right' name={`items.${index}.quantity`} />
|
||||||
scale={0}
|
|
||||||
className='text-right'
|
|
||||||
name={`items.${index}.quantity`}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 75,
|
size: 75,
|
||||||
@ -205,13 +200,13 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
},
|
},
|
||||||
size: 150,
|
size: 150,
|
||||||
},
|
},
|
||||||
], [currency, language, t]);
|
],
|
||||||
const columns = useDetailColumns(detailColumns,
|
[currency, language, t]
|
||||||
{
|
);
|
||||||
|
const columns = useDetailColumns(detailColumns, {
|
||||||
enableDragHandleColumn: true,
|
enableDragHandleColumn: true,
|
||||||
enableSelectionColumn: true,
|
enableSelectionColumn: true,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const handleAppendCatalogArticle = useCallback(
|
const handleAppendCatalogArticle = useCallback(
|
||||||
(article: any, quantity: number = 1) => {
|
(article: any, quantity: number = 1) => {
|
||||||
@ -266,29 +261,67 @@ export const QuoteDetailsCardEditor = ({
|
|||||||
const defaultLayout = [265, 440, 655];
|
const defaultLayout = [265, 440, 655];
|
||||||
const navCollapsedSize = 4;
|
const navCollapsedSize = 4;
|
||||||
|
|
||||||
const pickCatalogArticle = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setArticlePickerDialogOpen(true); };
|
const pickCatalogArticle = (chapterId?: string) => {
|
||||||
const pickBlock = (chapterId?: string) => { chapterEditor.capturePickerTarget(chapterId); setBlockPickerDialogOpen(true); };
|
chapterEditor.capturePickerTarget(chapterId);
|
||||||
const onShowLineDiscountColumnChange = (value: boolean) => setValue("show_line_discount_column", value, { shouldDirty: true });
|
setArticlePickerDialogOpen(true);
|
||||||
|
};
|
||||||
|
const pickBlock = (chapterId?: string) => {
|
||||||
|
chapterEditor.capturePickerTarget(chapterId);
|
||||||
|
setBlockPickerDialogOpen(true);
|
||||||
|
};
|
||||||
|
const onShowLineDiscountColumnChange = (value: boolean) =>
|
||||||
|
setValue("show_line_discount_column", value, { shouldDirty: true });
|
||||||
const table = chapterEditor.chapters.length ? (
|
const table = chapterEditor.chapters.length ? (
|
||||||
<QuoteChaptersDataTable controller={chapterEditor} columns={columns} data={fields}
|
<QuoteChaptersDataTable
|
||||||
defaultItem={defaultValues?.items[0]} currencyCode={currency.code}
|
controller={chapterEditor}
|
||||||
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
|
columns={columns}
|
||||||
|
data={fields}
|
||||||
|
defaultItem={defaultValues?.items[0]}
|
||||||
|
currencyCode={currency.code}
|
||||||
|
showLineDiscountColumn={showLineDiscountColumn}
|
||||||
|
hasLineDiscounts={hasLineDiscounts}
|
||||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||||
onAddChapter={() => setCreateChapterOpen(true)} pickCatalogArticle={pickCatalogArticle} pickBlock={pickBlock} />
|
onAddChapter={() => setCreateChapterOpen(true)}
|
||||||
|
pickCatalogArticle={pickCatalogArticle}
|
||||||
|
pickBlock={pickBlock}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<QuoteItemsSortableDataTable actions={{ ...fieldActions, getItem: (rowIndex) => getValues(`items.${rowIndex}`), pickCatalogArticle, pickBlock }}
|
<QuoteItemsSortableDataTable
|
||||||
columns={columns} data={fields} defaultValues={defaultValues}
|
actions={{
|
||||||
extraControls={<Button type="button" variant="ghost" size="sm" onClick={() => setCreateChapterOpen(true)}>{t("quotes.chapters.add")}</Button>}
|
...fieldActions,
|
||||||
showLineDiscountColumn={showLineDiscountColumn} hasLineDiscounts={hasLineDiscounts}
|
getItem: (rowIndex) => getValues(`items.${rowIndex}`),
|
||||||
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange} />
|
pickCatalogArticle,
|
||||||
|
pickBlock,
|
||||||
|
}}
|
||||||
|
columns={columns}
|
||||||
|
data={fields}
|
||||||
|
defaultValues={defaultValues}
|
||||||
|
extraControls={
|
||||||
|
<Button type='button' variant='ghost' size='sm' onClick={() => setCreateChapterOpen(true)}>
|
||||||
|
{t("quotes.chapters.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
showLineDiscountColumn={showLineDiscountColumn}
|
||||||
|
hasLineDiscounts={hasLineDiscounts}
|
||||||
|
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
const createChapterDialog = <QuoteChapterCreateDialog open={createChapterOpen} onOpenChange={setCreateChapterOpen}
|
const createChapterDialog = (
|
||||||
|
<QuoteChapterCreateDialog
|
||||||
|
open={createChapterOpen}
|
||||||
|
onOpenChange={setCreateChapterOpen}
|
||||||
onCreate={(description) => {
|
onCreate={(description) => {
|
||||||
const before = chapterEditor.read();
|
const before = chapterEditor.read();
|
||||||
const next = addChapter(before, description, chapterEditor.activeId);
|
const next = addChapter(before, description, chapterEditor.activeId);
|
||||||
chapterEditor.apply(next);
|
chapterEditor.apply(next);
|
||||||
chapterEditor.focusTitle(next.chapters.find((chapter) => !before.chapters.some((entry) => entry.id === chapter.id))!.id);
|
chapterEditor.focusTitle(
|
||||||
}} />;
|
next.chapters.find(
|
||||||
|
(chapter) => !before.chapters.some((entry) => entry.id === chapter.id)
|
||||||
|
)!.id
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (pickerMode === "dialog") {
|
if (pickerMode === "dialog") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -18,7 +18,9 @@ export function useChapterCollapse() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentRevision = ++revision.current;
|
const currentRevision = ++revision.current;
|
||||||
const contents = Array.from(ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []);
|
const contents = Array.from(
|
||||||
|
ref.current?.querySelectorAll<HTMLElement>("[data-chapter-content]") ?? []
|
||||||
|
);
|
||||||
const rows = Array.from(new Set(contents.map((content) => content.closest("tr")!)));
|
const rows = Array.from(new Set(contents.map((content) => content.closest("tr")!)));
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
row.toggleAttribute("inert", collapsed);
|
row.toggleAttribute("inert", collapsed);
|
||||||
@ -44,8 +46,11 @@ export function useChapterCollapse() {
|
|||||||
animations.current.forEach((animation) => animation.cancel());
|
animations.current.forEach((animation) => animation.cancel());
|
||||||
animations.current = [];
|
animations.current = [];
|
||||||
|
|
||||||
if (!contents.length || typeof contents[0].animate !== "function" ||
|
if (
|
||||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
!contents.length ||
|
||||||
|
typeof contents[0].animate !== "function" ||
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||||
|
) {
|
||||||
setHidden(collapsed);
|
setHidden(collapsed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -62,8 +67,16 @@ export function useChapterCollapse() {
|
|||||||
const options: KeyframeAnimationOptions = { duration, easing: "ease-in-out", fill: "both" };
|
const options: KeyframeAnimationOptions = { duration, easing: "ease-in-out", fill: "both" };
|
||||||
animations.current = [
|
animations.current = [
|
||||||
...contents.map((content, index) => content.animate([
|
...contents.map((content, index) => content.animate([
|
||||||
{ height: `${contentStarts[index].height}px`, opacity: contentStarts[index].opacity, overflow: "hidden" },
|
{
|
||||||
{ height: `${collapsed ? 0 : contentEnds[index]}px`, opacity: collapsed ? 0 : 1, overflow: "hidden" },
|
height: `${contentStarts[index].height}px`,
|
||||||
|
opacity: contentStarts[index].opacity,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
height: `${collapsed ? 0 : contentEnds[index]}px`,
|
||||||
|
opacity: collapsed ? 0 : 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
], options)),
|
], options)),
|
||||||
...cells.map((cell, index) => cell.animate([cellStarts[index], cellEnds[index]], options)),
|
...cells.map((cell, index) => cell.animate([cellStarts[index], cellEnds[index]], options)),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export function useDetailColumns<TData = unknown, TValue = unknown>(
|
|||||||
disabled={!row.getCanSelect()}
|
disabled={!row.getCanSelect()}
|
||||||
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
|
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
|
||||||
aria-label={t("common.select_row")}
|
aria-label={t("common.select_row")}
|
||||||
className='mt-2'
|
className='mt-2 opacity-0 data-[state=checked]:opacity-100 transition-opacity group-hover/detail:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [@media(hover:none)]:opacity-100'
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
|||||||
@ -364,3 +364,47 @@ Inconsistencias observadas, sin cambios fuera del alcance:
|
|||||||
- Validación: 28 pruebas de editor/barra y cinco de traducciones; TypeScript sin emisión; ESLint acotado sin errores (tres advertencias `any` previas del editor); Tailwind compilado en `/tmp`; `git diff --check`. Persisten los avisos previos de paginación fuera de `act` y de Browserslist desactualizado.
|
- Validación: 28 pruebas de editor/barra y cinco de traducciones; TypeScript sin emisión; ESLint acotado sin errores (tres advertencias `any` previas del editor); Tailwind compilado en `/tmp`; `git diff --check`. Persisten los avisos previos de paginación fuera de `act` y de Browserslist desactualizado.
|
||||||
- Revisión local con Chromium a 1440 y 375 px sobre HTML del harness: badge sobre carpeta, una cabecera para el capítulo con contenido y tres acciones en el vacío. En móvil la tabla mantiene su desplazamiento horizontal. Artefactos temporales `/tmp/uecko-chapter-empty-*`; el fixture temporal se retiró. No es una prueba E2E con API autenticada.
|
- Revisión local con Chromium a 1440 y 375 px sobre HTML del harness: badge sobre carpeta, una cabecera para el capítulo con contenido y tres acciones en el vacío. En móvil la tabla mantiene su desplazamiento horizontal. Artefactos temporales `/tmp/uecko-chapter-empty-*`; el fixture temporal se retiró. No es una prueba E2E con API autenticada.
|
||||||
- No se ejecutan base de datos, PDF ni builds de distribución: esta ampliación afecta a la presentación e inserción de detalles del editor, sin cambios en persistencia o informes.
|
- No se ejecutan base de datos, PDF ni builds de distribución: esta ampliación afecta a la presentación e inserción de detalles del editor, sin cambios en persistencia o informes.
|
||||||
|
|
||||||
|
### Ajuste: alineación del arrastre y plegado animado
|
||||||
|
|
||||||
|
- `QuoteChaptersDataTable.tsx`: el botón de arrastre del título usa el mismo ancho de 16 px que el de las líneas. Ambos iconos comparten eje horizontal, conservando el orden de columnas y el badge de la carpeta.
|
||||||
|
- `useChapterCollapse.ts`: transición de altura y opacidad de 200 ms mediante Web Animations API. Se animan el contenido y el espaciado de las celdas antes de ocultar sus filas; incluye cabeceras, detalles, acciones y estado vacío. Los campos permanecen montados; título y subtotal siguen visibles. Se respeta `prefers-reduced-motion` y se pliega inmediatamente cuando no hay soporte para animación.
|
||||||
|
- Durante el cierre, las filas dejan de ser interactivas mediante `inert` y `aria-hidden`; el arrastre de detalles/destinos vacíos se desactiva desde el inicio. Al invertir el sentido, se cancela la transición anterior y se parte de la posición actual. Al desmontar, se cancelan las animaciones pendientes.
|
||||||
|
- Dos pruebas nuevas del editor verifican ocultación tras finalizar, reversión sin ocultaciones obsoletas, conservación de valores y movimiento reducido. Validación: 26 pruebas del editor, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación React/TanStack fuera de `act` en la suite completa; las nuevas pruebas aisladas pasan sin ese aviso.
|
||||||
|
- Chromium local verifica geometría sobre el HTML actual del harness y ejecuta el hook real: centros de ambos iconos en 117,53125 px; altura del grupo de 222 a 117 px con estados intermedios de 170 y 184,6875 px; recuperación de altura/valores originales y ausencia de animación con movimiento reducido. Artefactos en `/tmp/uecko-chapter-animation-*`; fixture temporal retirado. Esta comprobación no es un E2E con la API autenticada.
|
||||||
|
- No se ejecutan builds de distribución, PDF ni base de datos: son cambios de presentación del editor, sin cambios de persistencia o informes. La compilación Tailwind se dirige a `/tmp` y conserva el aviso previo de Browserslist desactualizado.
|
||||||
|
|
||||||
|
### Corrección: resaltado conjunto de detalle activo y acciones
|
||||||
|
|
||||||
|
- `QuoteItemsSortableTableRow.tsx` identifica la fila activa mediante `data-active-item`. `QuoteChaptersDataTable.tsx` aplica el contorno a sus celdas, porque los bordes de las filas no se dibujan en la tabla con `border-separate`.
|
||||||
|
- Detalle y acciones comparten fondo rojo claro, borde izquierdo rojo de 4 px y contorno exterior fino; se retira el separador intermedio. Las acciones ocupan todo el ancho de la tabla y empiezan junto al borde izquierdo, como en la presentación original. El estilo de selección no se aplica a los capítulos ni a otras líneas.
|
||||||
|
- Validación: 26 pruebas existentes del editor, TypeScript sin emisión, ESLint acotado sin errores (dos advertencias previas en el componente de fila) y `git diff --check`. Chromium local verifica igualdad de fondos, continuidad del contorno y ausencia de espacio o borde entre detalle y acciones. Muestra en `/tmp/uecko-active-row.png`, generada desde el harness del editor; fixture temporal retirado.
|
||||||
|
- No se ejecutan base de datos, PDF, builds de distribución ni E2E autenticados: es una corrección de estilos. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
|
||||||
|
|
||||||
|
### Ajuste: selección exclusiva de líneas de detalle
|
||||||
|
|
||||||
|
- Se elimina el checkbox de los títulos de capítulo, manteniendo su celda vacía para alinear las columnas. La selección se limita a los detalles; el checkbox de cabecera de columnas conserva la selección conjunta de los detalles de ese capítulo.
|
||||||
|
- Se retira del editor el estado de selección múltiple de capítulos y su bloqueo por selección mixta. Los capítulos se mueven individualmente con todo su contenido y conservan sus botones propios de duplicar/eliminar. La barra actúa sobre los detalles seleccionados.
|
||||||
|
- Se adaptan las pruebas existentes de duplicación, selección y alineación: 26 pruebas del editor correctas, TypeScript sin emisión, ESLint acotado sin errores y `git diff --check`. Persiste el aviso previo de paginación fuera de `act`. No se ejecutan PDF, base de datos ni E2E: el cambio se limita a la selección en el editor.
|
||||||
|
|
||||||
|
### Ajuste: controles visibles al pasar el cursor
|
||||||
|
|
||||||
|
- Las filas de detalle muestran arrastre y selección al pasar el cursor por su propia fila, con y sin capítulos. El checkbox marcado permanece visible aunque el cursor salga; al desmarcarlo vuelve a ocultarse. El asa también permanece visible durante el arrastre.
|
||||||
|
- En el título de capítulo se muestran arrastre, duplicar y eliminar únicamente al pasar por ese título; el hover de los detalles no activa esos botones. Carpeta y badge siguen visibles. Se usan grupos Tailwind con nombre por tipo de fila y opacidad para mantener la alineación.
|
||||||
|
- Cada control se revela también al recibir foco de teclado. En dispositivos sin hover permanecen visibles para permitir su uso táctil. La transición de opacidad respeta movimiento reducido.
|
||||||
|
- Cambios en `QuoteChaptersDataTable.tsx`, `QuoteItemsSortableTableRow.tsx`, `QuoteItemsSortableDragHandleCell.tsx` y `useDetailColumns.tsx`. Validación: 27 pruebas existentes de editor/arrastre; ESLint acotado sin errores (dos advertencias previas de la fila); Tailwind compilado en `/tmp`; `git diff --check`. La última excepción de checkbox marcado se comprueba además con CSS real en Chromium.
|
||||||
|
- Chromium local verifica ocultación, hover por fila, checkbox marcado/desmarcado, foco de teclado y controles táctiles, con y sin capítulos. Para comprobar selectores de escritorio se desactiva solo la regla táctil en el fixture, ya que Chromium sin interfaz informa `hover:none`; la prueba táctil usa la hoja original. Artefactos `/tmp/uecko-row-controls-*`; fixture temporal retirado. No es un E2E autenticado.
|
||||||
|
- No se ejecutan TypeScript, builds de distribución, PDF ni base de datos: el cambio solo añade clases y no altera tipos, datos o informes. Persisten los avisos previos de paginación fuera de `act` y Browserslist desactualizado.
|
||||||
|
|
||||||
|
### Ajuste: márgenes simétricos de carpeta y eliminar capítulo
|
||||||
|
|
||||||
|
- `QuoteChaptersDataTable.tsx`: aumenta el espacio izquierdo de la carpeta y amplía la columna de despliegue de 44 a 56 px para alojarlo sin invadir la columna de arrastre. Conserva tamaños de iconos y botones, badge y alineación de detalles.
|
||||||
|
- Chromium local mide 20 px tanto entre el borde izquierdo y la carpeta como entre la papelera y el borde derecho, en ambos capítulos a 1440 y 1024 px. El eje de las asas de título/detalle sigue coincidiendo (diferencia 0 px). HTML exportado del harness en `/tmp/uecko-chapter-spacing.html`; fixture temporal retirado.
|
||||||
|
- Validación acotada: cuatro casos existentes de alineación/orden de columnas, ESLint del componente, compilación Tailwind hacia `/tmp` y `git diff --check`. No se ejecutan TypeScript, PDF, base de datos, builds de distribución ni E2E autenticados por ser un ajuste de espaciado. Persiste el aviso previo de Browserslist desactualizado.
|
||||||
|
|
||||||
|
### Ajuste: título alineado con el arrastre de sus conceptos
|
||||||
|
|
||||||
|
- El título de capítulo ocupa las columnas posteriores a la carpeta y empieza en el mismo eje vertical que el icono de arrastre de sus detalles. El asa del capítulo se sitúa a la derecha del título, junto a duplicar y eliminar; conserva eventos de arrastre, foco y visibilidad al pasar el cursor.
|
||||||
|
- `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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user