4215 lines
208 KiB
TypeScript
4215 lines
208 KiB
TypeScript
import { createSignal, createEffect, createMemo, onCleanup, untrack, on, For, Show, JSXElement, JSX } from "solid-js";
|
|
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
|
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
|
// The worker reads import.meta.url, so it must load from a real URL (not a
|
|
// Blob). The bundler's asset-url plugin emits it from frontend/vendor into
|
|
// wwwroot/vendor and resolves this import to its served URL.
|
|
import { FormInput, FormSelect, FormMultiSelect, FormMultiSelectTrigger, FormCombobox, FormLabel, type FormSelectOption } from "./Forms.tsx";
|
|
import { DatePicker } from "./DatePicker.tsx";
|
|
import {Icon} from "./Icons.tsx";
|
|
import { Menu, MenuContent, MenuDivider, MenuItem, MenuSection, MenuTrigger } from "./Menu.tsx";
|
|
import { Popover, PopoverContent, PopoverTrigger, HoverPopover, HoverPopoverTrigger, HoverPopoverContent } from "./Popovers.tsx";
|
|
import { useFloatingContext } from "./Floating.tsx";
|
|
import { BUTTON_COLOR_LIGHT_NEUTRAL, BUTTON_COLOR_PRIMARY, ButtonUI, ButtonLinkRed, SegmentedButtons } from "./Buttons.tsx";
|
|
import { authFetch } from "../auth/useAuthFetch.js";
|
|
import { compareRowsGeneric } from "./CellGrid.tsx";
|
|
import { accessor, readAccessor, type MaybeAccessor } from "../utils/accessors.ts";
|
|
import { formatNumber, formatDecimal } from "./Formatters.ts";
|
|
|
|
const pdfWorkerUrl = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
|
|
|
if (!GlobalWorkerOptions.workerSrc) {
|
|
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
}
|
|
|
|
export type ColumnPosition = 0 | 1 | 2;
|
|
export type AutoTableHeaderColor = 0 | 1 | 2 | 3 | 4;
|
|
export type AutoTableSize = 0 | 1 | 2;
|
|
export type PDFOrientation = 0 | 1;
|
|
|
|
// Predefined spreadsheet-style functions for calculated columns. A fixed
|
|
// enumeration whose behavior is obvious and reviewable: "sum/average/min/max/
|
|
// count" ignore blank/non-numeric operands (like Excel's aggregate functions);
|
|
// "subtract/multiply/divide" require every operand to resolve to a number,
|
|
// otherwise the cell shows `emptyValue`. The "custom" function instead evaluates
|
|
// an Excel-style `formula` expression (see compileFormula).
|
|
export type CalculatedFunction =
|
|
| "sum"
|
|
| "subtract"
|
|
| "multiply"
|
|
| "divide"
|
|
| "average"
|
|
| "median"
|
|
| "mode"
|
|
| "min"
|
|
| "max"
|
|
| "count";
|
|
|
|
// How a calculated result is formatted for display/CSV/PDF.
|
|
export type CalculatedDataType = "plain" | "number" | "integer" | "decimal" | "money" | "percent";
|
|
|
|
// A user-created calculated column, configured at runtime through the UI and
|
|
// (optionally) persisted to localStorage. For predefined functions, `operands`
|
|
// reference other columns by key — a data column's sortIdentifier, or
|
|
// "_calc_<id>" to reference another calculated column. For fn "custom",
|
|
// `formula` holds an Excel-style expression that references columns as
|
|
// [Display Name] (see compileFormula).
|
|
export interface UserCalculatedColumn {
|
|
id: string;
|
|
displayName: string;
|
|
fn: CalculatedFunction | "custom";
|
|
operands: string[];
|
|
formula?: string;
|
|
dataType?: CalculatedDataType;
|
|
precision?: number;
|
|
displayPosition?: ColumnPosition; // header + cell alignment (default right)
|
|
}
|
|
|
|
// A user-created footer summary line (Total, Subtotal, …). Unlike a calculated
|
|
// column, the formula is evaluated ONCE (no current row), so [cell] refs are NaN
|
|
// and only whole-column aggregates like SUM({Revenue}) are meaningful. The single
|
|
// result is shown right-aligned in the footer (rightmost column), with `label` to
|
|
// its left.
|
|
export interface UserSummaryRow {
|
|
id: string;
|
|
label: string;
|
|
// Basic mode: a predefined fn aggregating `operands[0]` down the rows.
|
|
// Advanced mode: fn "custom" with an Excel-style `formula`.
|
|
fn?: CalculatedFunction | "custom";
|
|
operands?: string[];
|
|
formula?: string;
|
|
dataType?: CalculatedDataType;
|
|
precision?: number;
|
|
}
|
|
|
|
const CALC_DATATYPE_OPTIONS: { value: CalculatedDataType; label: string }[] = [
|
|
{ value: "number", label: "Number" },
|
|
{ value: "integer", label: "Integer" },
|
|
{ value: "decimal", label: "Decimal" },
|
|
{ value: "money", label: "Money" },
|
|
{ value: "percent", label: "Percent" },
|
|
{ value: "plain", label: "Plain" },
|
|
];
|
|
|
|
// Basic-mode functions. For a calculated COLUMN these combine the chosen columns
|
|
// per row (column-based); binary ones (subtract/divide) take two ordered columns,
|
|
// the rest take a multi-select. For a SUMMARY they aggregate one column down the
|
|
// rows (row-based), so only the aggregates apply.
|
|
const CALC_FUNCTION_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "sum", label: "Sum" },
|
|
{ value: "subtract", label: "Subtract" },
|
|
{ value: "multiply", label: "Multiply" },
|
|
{ value: "divide", label: "Divide" },
|
|
{ value: "average", label: "Average (mean)" },
|
|
{ value: "median", label: "Median" },
|
|
{ value: "mode", label: "Mode" },
|
|
{ value: "min", label: "Minimum" },
|
|
{ value: "max", label: "Maximum" },
|
|
{ value: "count", label: "Count" },
|
|
];
|
|
const SUMMARY_FUNCTION_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: "sum", label: "Sum" },
|
|
{ value: "average", label: "Average (mean)" },
|
|
{ value: "median", label: "Median" },
|
|
{ value: "mode", label: "Mode" },
|
|
{ value: "min", label: "Minimum" },
|
|
{ value: "max", label: "Maximum" },
|
|
{ value: "count", label: "Count" },
|
|
];
|
|
const isBinaryCalcFn = (fn: string): boolean => fn === "subtract" || fn === "divide";
|
|
|
|
// Functions offered by the editor's "Function" menu. Names must match the
|
|
// formula engine (see makeFormulaFunction); each is inserted as NAME() with the
|
|
// caret placed between the parentheses.
|
|
const FORMULA_FUNCTION_GROUPS: { label: string; fns: { name: string; sig: string; desc: string }[] }[] = [
|
|
{ label: "Aggregate", fns: [
|
|
{ name: "SUM", sig: "SUM(range)", desc: "Total of the values" },
|
|
{ name: "AVERAGE", sig: "AVERAGE(range)", desc: "Mean of the values" },
|
|
{ name: "MEDIAN", sig: "MEDIAN(range)", desc: "Middle value" },
|
|
{ name: "MODE", sig: "MODE(range)", desc: "Most frequent value" },
|
|
{ name: "MIN", sig: "MIN(range)", desc: "Smallest value" },
|
|
{ name: "MAX", sig: "MAX(range)", desc: "Largest value" },
|
|
{ name: "COUNT", sig: "COUNT(range)", desc: "How many numbers" },
|
|
] },
|
|
{ label: "Math", fns: [
|
|
{ name: "ABS", sig: "ABS(n)", desc: "Absolute value" },
|
|
{ name: "ROUND", sig: "ROUND(n, digits)", desc: "Round to digits" },
|
|
{ name: "FLOOR", sig: "FLOOR(n)", desc: "Round down" },
|
|
{ name: "CEILING", sig: "CEILING(n)", desc: "Round up" },
|
|
{ name: "SQRT", sig: "SQRT(n)", desc: "Square root" },
|
|
{ name: "POWER", sig: "POWER(n, p)", desc: "n to the power p" },
|
|
{ name: "MOD", sig: "MOD(n, d)", desc: "Remainder of n ÷ d" },
|
|
{ name: "EXP", sig: "EXP(n)", desc: "e to the power n" },
|
|
{ name: "LN", sig: "LN(n)", desc: "Natural log (base e)" },
|
|
{ name: "LOG", sig: "LOG(n, base)", desc: "Log, base 10 by default" },
|
|
] },
|
|
{ label: "Trigonometry", fns: [
|
|
{ name: "SIN", sig: "SIN(angle)", desc: "Sine (radians)" },
|
|
{ name: "COS", sig: "COS(angle)", desc: "Cosine (radians)" },
|
|
{ name: "TAN", sig: "TAN(angle)", desc: "Tangent (radians)" },
|
|
{ name: "ASIN", sig: "ASIN(n)", desc: "Inverse sine" },
|
|
{ name: "ACOS", sig: "ACOS(n)", desc: "Inverse cosine" },
|
|
{ name: "ATAN", sig: "ATAN(n)", desc: "Inverse tangent" },
|
|
{ name: "ATAN2", sig: "ATAN2(x, y)", desc: "Angle of point (x, y)" },
|
|
{ name: "SINH", sig: "SINH(n)", desc: "Hyperbolic sine" },
|
|
{ name: "COSH", sig: "COSH(n)", desc: "Hyperbolic cosine" },
|
|
{ name: "TANH", sig: "TANH(n)", desc: "Hyperbolic tangent" },
|
|
{ name: "PI", sig: "PI()", desc: "π constant" },
|
|
{ name: "RADIANS", sig: "RADIANS(deg)", desc: "Degrees → radians" },
|
|
{ name: "DEGREES", sig: "DEGREES(rad)", desc: "Radians → degrees" },
|
|
] },
|
|
{ label: "Logic", fns: [
|
|
{ name: "IF", sig: "IF(test, then, else)", desc: "Choose by condition" },
|
|
{ name: "AND", sig: "AND(a, b, …)", desc: "True if all are true" },
|
|
{ name: "OR", sig: "OR(a, b, …)", desc: "True if any are true" },
|
|
{ name: "NOT", sig: "NOT(a)", desc: "Negate" },
|
|
] },
|
|
{ label: "Row", fns: [
|
|
{ name: "ROW", sig: "ROW()", desc: "Current row number" },
|
|
] },
|
|
];
|
|
|
|
// Named mathematical constants — written bare in a formula (e.g. 2 * PI * [r]).
|
|
const FORMULA_CONSTANTS: Record<string, number> = {
|
|
PI: Math.PI,
|
|
E: Math.E,
|
|
TAU: Math.PI * 2,
|
|
PHI: (1 + Math.sqrt(5)) / 2,
|
|
SQRT2: Math.SQRT2,
|
|
};
|
|
const FORMULA_CONSTANT_OPTIONS: { name: string; desc: string }[] = [
|
|
{ name: "PI", desc: "π ≈ 3.14159" },
|
|
{ name: "E", desc: "Euler's number ≈ 2.71828" },
|
|
{ name: "TAU", desc: "2π ≈ 6.28319" },
|
|
{ name: "PHI", desc: "Golden ratio ≈ 1.61803" },
|
|
{ name: "SQRT2", desc: "√2 ≈ 1.41421" },
|
|
];
|
|
|
|
|
|
function makeCalcColumnId(): string {
|
|
return "c" + Date.now().toString(36) + Math.floor(Math.random() * 0x1000000).toString(36);
|
|
}
|
|
|
|
export interface CalculatedColumnSpec {
|
|
fn: CalculatedFunction | "custom";
|
|
// Operands are either a row field key (its raw value is parsed as a number,
|
|
// tolerating "$", ",", "%" and whitespace) or a numeric literal constant.
|
|
operands: (string | number)[];
|
|
dataType?: CalculatedDataType; // drives default formatting
|
|
// Default display formatting (ignored when `format` is supplied):
|
|
precision?: number; // fixed number of decimal places
|
|
prefix?: string; // e.g. "$"
|
|
suffix?: string; // e.g. "%"
|
|
emptyValue?: string; // shown when the result isn't a finite number (default "—")
|
|
// Full override of display/CSV/PDF text. Receives the computed number (which
|
|
// may be NaN/Infinity) and the row.
|
|
format?: (result: number, item: any) => any;
|
|
// Overrides operand/fn computation. User columns set this to resolve
|
|
// calc-in-calc references and custom formulas; see computeCalculatedValue.
|
|
compute?: (item: any) => number;
|
|
}
|
|
|
|
export interface AutoTableColumn {
|
|
displayName: string;
|
|
displayPosition?: ColumnPosition;
|
|
sortable?: boolean;
|
|
sortIdentifier?: string;
|
|
// sortType controls the local-sort comparator (see compareRowsGeneric):
|
|
// "numeric" sorts by leading digits then suffix (so "4" < "33", "4NY" < "4X"),
|
|
// "money" by parsed float, otherwise locale string compare. Only applies to
|
|
// client-side (local) sorting; server-side sorting is driven by the backend.
|
|
sortType?: string;
|
|
// sortValue extracts the value to compare for this column when sorting
|
|
// locally (e.g. a computed column). Defaults to row[sortIdentifier].
|
|
sortValue?: (item: any) => unknown;
|
|
headerClasses?: string;
|
|
csv?: boolean;
|
|
csvValue?: (item: any) => any;
|
|
toggleable?: boolean;
|
|
hiddenByDefault?: boolean;
|
|
// Marks this as a calculated column: AutoTable renders, exports, and sorts
|
|
// its value itself from `calculated`, so your cellRenderer does NOT handle
|
|
// it. Calculated columns require `cellRenderer` (not `rowRenderer`) because
|
|
// the table needs per-cell control to inject the computed value, and are
|
|
// evaluated client-side only. See computeCalculatedValue.
|
|
calculated?: CalculatedColumnSpec;
|
|
}
|
|
|
|
export interface PDFBelowTableContext {
|
|
page: any;
|
|
y: number;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
margin: number;
|
|
contentWidth: number;
|
|
font: any;
|
|
fontBold: any;
|
|
colors: { text: any; muted: any; border: any };
|
|
response: any;
|
|
pdf: any;
|
|
addPage: () => { page: any; y: number };
|
|
}
|
|
|
|
export interface AutoTablePDFHeader {
|
|
title?: string;
|
|
subtitle?: string;
|
|
showDate?: boolean;
|
|
logoUrl?: string;
|
|
showLogo?: boolean;
|
|
orientation?: PDFOrientation;
|
|
belowTable?: (ctx: PDFBelowTableContext) => void | Promise<void>;
|
|
}
|
|
|
|
export interface AutoTableOptions {
|
|
size?: AutoTableSize;
|
|
shadow?: boolean;
|
|
hover?: boolean;
|
|
alternate?: boolean;
|
|
headerBorderY?: boolean;
|
|
surroundingBorder?: boolean;
|
|
borderX?: boolean;
|
|
borderY?: boolean;
|
|
color?: AutoTableHeaderColor;
|
|
tableLayoutAuto?: boolean;
|
|
hidePagination?: boolean;
|
|
paginationShowAll?: boolean;
|
|
draggableColumns?: boolean;
|
|
columnOrderStorageKey?: string;
|
|
toggleColumns?: boolean;
|
|
columnVisibilityStorageKey?: string;
|
|
// Let users drag the right edge of a column header to resize it. Works best
|
|
// with the default fixed table layout (not tableLayoutAuto).
|
|
resizableColumns?: boolean;
|
|
// Persist user-set column widths (px, keyed by column) to localStorage.
|
|
columnWidthStorageKey?: string;
|
|
// Show an in-table Reset menu to restore column order/widths/calculated
|
|
// columns (only the resets for enabled features are listed).
|
|
resetButton?: boolean;
|
|
exportCSV?: boolean;
|
|
exportFilename?: string;
|
|
accordion?: boolean;
|
|
accordionSingle?: boolean;
|
|
pdfHeader?: AutoTablePDFHeader;
|
|
// Render `searchFields` as a card to the left of the table instead of inline
|
|
// above it. The toolbar (export, column toggles, custom actions) stays on top.
|
|
searchAside?: boolean;
|
|
/** Place export/column toggles at the end of the inline filter row (desktop). */
|
|
inlineToolbar?: boolean;
|
|
// Let end users add/edit/remove their own calculated columns at runtime via
|
|
// a toolbar button. Calculated columns are appended after the data columns,
|
|
// computed client-side from the table's numeric columns.
|
|
calculatedColumns?: boolean;
|
|
// Persist user-created calculated columns to localStorage under this key.
|
|
calculatedColumnsStorageKey?: string;
|
|
// Let end users add/edit/remove footer summary lines (Total, Subtotal, …) at
|
|
// runtime via a toolbar button. Each is a formula evaluated once over the
|
|
// filtered rows, shown in a <tfoot> aligned under the chosen column.
|
|
summaryRows?: boolean;
|
|
// Persist user-created summary rows to localStorage under this key.
|
|
summaryRowsStorageKey?: string;
|
|
// When true, the Export button opens a popover (instead of the menu) that
|
|
// lets the user edit otherwise developer-fixed export settings — file name,
|
|
// PDF title/subtitle, orientation, show date/logo — before downloading.
|
|
userCustomizeExport?: boolean;
|
|
}
|
|
|
|
export interface AutoTableSearchEntry {
|
|
Identifier: string;
|
|
Values: string[];
|
|
Exact?: boolean;
|
|
}
|
|
|
|
export interface AutoTablePagination {
|
|
Disabled?: boolean;
|
|
CurrentPage: number;
|
|
NextPage?: number;
|
|
PreviousPage?: number;
|
|
TotalPages: number;
|
|
TotalItems: number;
|
|
MaxItemsPerPage: number;
|
|
ItemsThisPage?: number;
|
|
ViewRangeLower: number;
|
|
ViewRangeUpper: number;
|
|
}
|
|
|
|
export interface AutoTableOrderBy {
|
|
Identifier: string;
|
|
Descending: boolean;
|
|
}
|
|
|
|
export interface AutoTableFilter {
|
|
Pagination: AutoTablePagination;
|
|
OrderBy: AutoTableOrderBy;
|
|
Search: AutoTableSearchEntry[];
|
|
}
|
|
|
|
export interface SearchFieldsContext {
|
|
getSearchValue: (identifier: string) => string;
|
|
setSearchValue: (identifier: string, value: string) => void;
|
|
setSearchValueExact: (identifier: string, value: string) => void;
|
|
getSearchValues: (identifier: string) => string[];
|
|
setSearchValues: (identifier: string, values: string[]) => void;
|
|
getMultiSearchValue: (fields: string[]) => string;
|
|
setMultiSearchValue: (fields: string[], value: string) => void;
|
|
resetColumnOrder: () => void;
|
|
}
|
|
|
|
export interface ToolbarActionsContext {
|
|
allFilteredData: () => any[];
|
|
}
|
|
|
|
export interface AutoTableProps {
|
|
url?: string;
|
|
data?: any[];
|
|
remoteFiltering?: boolean;
|
|
requireAuth?: boolean;
|
|
// Reading this prop inside the initial remote-fetch effect lets a parent force
|
|
// a reload (e.g. after creating a row) by changing the tracked value. Passed as
|
|
// a signal accessor, wrapProps unwraps it to its current value here — so we read
|
|
// the property reactively rather than calling it.
|
|
refreshSignal?: unknown;
|
|
columns: AutoTableColumn[];
|
|
options?: AutoTableOptions;
|
|
initialFilter?: Partial<AutoTableFilter>;
|
|
accordionKey?: string;
|
|
searchFields?: (ctx: SearchFieldsContext) => any;
|
|
aboveTable?: any;
|
|
belowTable?: any;
|
|
emptyMessage?: string | (() => string);
|
|
rowRenderer?: (item: any, position: number, rowIdx: number) => any;
|
|
cellRenderer?: (item: any, col: AutoTableColumn, colIdx: number, position: number, rowIdx: number) => any;
|
|
accordionRenderer?: (item: any, accordionData: any) => any;
|
|
toolbarActions?: (ctx: ToolbarActionsContext) => any;
|
|
/** Extra items rendered inside the Export dropdown (e.g. QuickBooks CSV). */
|
|
exportMenuItems?: (ctx: ToolbarActionsContext) => any;
|
|
// Predicate that flags one or more rows for highlighting. When the
|
|
// returned predicate matches an item that lives on a different page than
|
|
// the one currently displayed, the table jumps to that page so the row is
|
|
// visible. Re-evaluated whenever the predicate function reference changes.
|
|
highlightMatch?: (item: any) => boolean;
|
|
// Called whenever the effective filter (search/sort/pagination) changes, so
|
|
// a parent can build features off the current query without re-deriving it
|
|
// (e.g. fetching the full result set to seed an "open as queue" action).
|
|
onFilterChange?: (filter: AutoTableFilter) => void;
|
|
}
|
|
|
|
export interface AutoTableSearchProps {
|
|
label: string;
|
|
placeholder?: string;
|
|
value: string;
|
|
onchange: (value: string) => void;
|
|
class?: string;
|
|
}
|
|
|
|
export interface AutoTableMultiSearchProps {
|
|
label?: string;
|
|
placeholder?: string;
|
|
value: string;
|
|
onchange: (value: string) => void;
|
|
class?: string;
|
|
}
|
|
|
|
const MULTI_SEARCH_PREFIX = "_multi_";
|
|
|
|
export function AutoTableSearch(props: AutoTableSearchProps) {
|
|
const fieldCls = () => props.class || AUTOTABLE_SEARCH_FIELD;
|
|
return <div class={fieldCls()}>
|
|
<label>{props.label}</label>
|
|
<FormInput
|
|
small={true}
|
|
placeholder={props.placeholder}
|
|
value={props.value}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
|
|
/>
|
|
</div>;
|
|
}
|
|
|
|
export interface AutoTableDateSearchProps {
|
|
label: string;
|
|
identifier?: string;
|
|
getSearchValue?: (identifier: string) => string;
|
|
value?: MaybeAccessor<string>;
|
|
onchange: (value: string) => void;
|
|
placeholder?: string;
|
|
class?: string;
|
|
}
|
|
|
|
export function AutoTableDateSearch(props: AutoTableDateSearchProps) {
|
|
const liveValue = () => {
|
|
if (props.identifier != null && props.getSearchValue) {
|
|
return props.getSearchValue(props.identifier);
|
|
}
|
|
return readAccessor(props.value, "");
|
|
};
|
|
|
|
const fieldCls = () => props.class || AUTOTABLE_DATE_SEARCH_FIELD;
|
|
return (
|
|
<div class={fieldCls()}>
|
|
<label>{props.label}</label>
|
|
<DatePicker
|
|
small={true}
|
|
clearable={true}
|
|
value={liveValue}
|
|
onchange={props.onchange}
|
|
placeholder={props.placeholder}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AutoTableMultiSearch(props: AutoTableMultiSearchProps) {
|
|
return <div class={AUTOTABLE_SEARCH_FIELD + " " + (props.class || "")}>
|
|
<Show when={props.label}>
|
|
<label>{props.label}</label>
|
|
</Show>
|
|
<FormInput
|
|
small={true}
|
|
placeholder={props.placeholder}
|
|
value={props.value}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
|
|
/>
|
|
</div>;
|
|
}
|
|
|
|
function createMultiSearchIdentifier(fields: string[]): string {
|
|
return MULTI_SEARCH_PREFIX + fields.join(",");
|
|
}
|
|
|
|
function isMultiSearchIdentifier(identifier: string): boolean {
|
|
return identifier.startsWith(MULTI_SEARCH_PREFIX);
|
|
}
|
|
|
|
function parseMultiSearchFields(identifier: string): string[] {
|
|
if (!isMultiSearchIdentifier(identifier)) return [];
|
|
return identifier.slice(MULTI_SEARCH_PREFIX.length).split(",");
|
|
}
|
|
|
|
export const COL_POS_LEFT: ColumnPosition = 0;
|
|
export const COL_POS_RIGHT: ColumnPosition = 1;
|
|
export const COL_POS_CENTER: ColumnPosition = 2;
|
|
|
|
// Alignment picker options for the calculated-column editor (value is the
|
|
// stringified ColumnPosition; declared after the COL_POS_* consts).
|
|
const CALC_POSITION_OPTIONS: { value: string; label: string }[] = [
|
|
{ value: String(COL_POS_LEFT), label: "Left" },
|
|
{ value: String(COL_POS_CENTER), label: "Center" },
|
|
{ value: String(COL_POS_RIGHT), label: "Right" },
|
|
];
|
|
|
|
export const AUTOTABLE_HEADER_COLOR_DEFAULT = 0;
|
|
export const AUTOTABLE_HEADER_COLOR_BLUE = 1;
|
|
export const AUTOTABLE_HEADER_COLOR_GREEN = 2;
|
|
export const AUTOTABLE_HEADER_COLOR_GRAY = 3;
|
|
export const AUTOTABLE_HEADER_COLOR_DARK_BLUE = 4;
|
|
|
|
export const AUTOTABLE_SIZE_DEFAULT: AutoTableSize = 0;
|
|
export const AUTOTABLE_SIZE_COMPACT: AutoTableSize = 1;
|
|
export const AUTOTABLE_SIZE_SUPERCOMPACT: AutoTableSize = 2;
|
|
|
|
export const PDF_ORIENTATION_LANDSCAPE: PDFOrientation = 0;
|
|
export const PDF_ORIENTATION_PORTRAIT: PDFOrientation = 1;
|
|
|
|
// -- Tailwind class maps ---------------------------------------------
|
|
// Background + text colors per header color.
|
|
export const HEADER_COLOR_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "bg-surface-muted",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "bg-sky-700 text-white",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "bg-green-700 text-white",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "bg-neutral-600 text-white",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "bg-sky-900 text-white",
|
|
};
|
|
|
|
// Sortable hover override per color (applied when `sortable`).
|
|
const HEADER_SORT_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-surface-strong",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-500",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-800",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-neutral-500",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-800",
|
|
};
|
|
|
|
// Header text weight/case styling per color.
|
|
export const HEADER_TEXT_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "font-bold uppercase tracking-wider",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "font-semibold",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "font-semibold",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "font-semibold",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "font-semibold",
|
|
};
|
|
|
|
// Drag-handle color (muted variant of header color).
|
|
const HEADER_DRAG_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-ink-muted",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "text-white/70",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "text-white/70",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "text-white/70",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white/70",
|
|
};
|
|
|
|
// Sort icon color (same as header text color).
|
|
const HEADER_SORT_ICON_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-ink",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "text-white",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "text-white",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "text-white",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white",
|
|
};
|
|
|
|
// Th cell padding per table size.
|
|
export const HEADER_PADDING_CLS: Record<AutoTableSize, string> = {
|
|
[AUTOTABLE_SIZE_DEFAULT]: "p-4 text-sm",
|
|
[AUTOTABLE_SIZE_COMPACT]: "py-1.5 px-2 text-sm",
|
|
[AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-2 text-xs",
|
|
};
|
|
|
|
// Body cell padding per table size — applied via [&_td]: on the tbody.
|
|
export const BODY_PADDING_CLS: Record<AutoTableSize, string> = {
|
|
[AUTOTABLE_SIZE_DEFAULT]: "text-sm [&_td]:p-4",
|
|
[AUTOTABLE_SIZE_COMPACT]: "text-sm [&_td]:py-1 [&_td]:px-2",
|
|
[AUTOTABLE_SIZE_SUPERCOMPACT]: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
|
};
|
|
|
|
// Pagination padding per table size.
|
|
const PAGINATION_PADDING_CLS: Record<AutoTableSize, string> = {
|
|
[AUTOTABLE_SIZE_DEFAULT]: "py-3 px-4",
|
|
[AUTOTABLE_SIZE_COMPACT]: "py-1 px-4",
|
|
[AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-4",
|
|
};
|
|
|
|
// Hover background per color (applied to body rows when `hover` is on).
|
|
const ROW_HOVER_CLS: Record<AutoTableHeaderColor, string> = {
|
|
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-surface-strong",
|
|
[AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-100 dark:bg-sky-950/50",
|
|
[AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-100 dark:bg-green-950/50",
|
|
[AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-surface-strong",
|
|
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-100 dark:bg-sky-950/50",
|
|
};
|
|
|
|
// Alignment per ColumnPosition.
|
|
export const POS_CLS: Record<ColumnPosition, string> = {
|
|
[COL_POS_LEFT]: "text-left",
|
|
[COL_POS_RIGHT]: "text-right",
|
|
[COL_POS_CENTER]: "text-center",
|
|
};
|
|
|
|
// Header inner flex direction per position.
|
|
export const HEADER_INNER_POS: Record<ColumnPosition, string> = {
|
|
[COL_POS_LEFT]: "",
|
|
[COL_POS_RIGHT]: "flex-row-reverse",
|
|
[COL_POS_CENTER]: "justify-center",
|
|
};
|
|
|
|
// -- Class string constants for parts that don't vary by config --
|
|
export const TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden";
|
|
export const TBL_WRAPPER = "overflow-x-auto w-full";
|
|
export const TBL_BASE = "min-w-full";
|
|
|
|
export const HEADER_CONTENT = "transition-transform duration-150 ease-in-out";
|
|
export const HEADER_INNER_BASE = "flex justify-between gap-2 items-center";
|
|
const DRAG_HANDLE = "opacity-40 shrink-0";
|
|
const SORT_ICON_WRAP = "leading-none shrink-0 opacity-50";
|
|
|
|
const ACCORDION_TOGGLE_TH = "w-10 text-center text-ink-faint";
|
|
const ACCORDION_TOGGLE_TD = "w-10 text-center text-ink-faint";
|
|
const ACCORDION_ICON = "inline-block transition-transform duration-200";
|
|
|
|
const SKELETON = "h-4 bg-surface-strong rounded-default animate-pulse";
|
|
const ERROR_CELL = "text-center text-red-600 dark:text-red-400";
|
|
const EMPTY_CELL = "text-center text-ink-muted";
|
|
|
|
const PAGINATION_BASE = "flex justify-between items-center border-t border-line-strong";
|
|
const PAGINATION_INFO = "hidden sm:flex items-center text-sm text-ink-muted";
|
|
const PAGINATION_CONTROLS = "flex items-center";
|
|
const PAGINATION_LABEL = "hidden sm:block text-sm text-ink-muted mr-2";
|
|
const PAGINATION_PAGE = "text-sm text-ink-muted px-3";
|
|
const PAGINATION_BTN = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed disabled:hover:bg-transparent";
|
|
|
|
const TOOLBAR = "min-w-0 max-w-full mb-1.5 overflow-visible";
|
|
const TOOLBAR_MOBILE_BAR = "flex flex-col items-stretch gap-2 mb-2 min-w-0 max-w-full lg:hidden overflow-visible";
|
|
const TOOLBAR_DESKTOP_ROW = "flex flex-col gap-3 min-w-0 max-w-full lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 overflow-visible";
|
|
const TOOLBAR_ACTIONS = "flex flex-wrap items-center gap-2 min-w-0 max-w-full shrink-0 lg:flex-nowrap lg:ml-auto lg:flex-[0_0_auto] lg:justify-end";
|
|
const TOOLBAR_ACTIONS_INLINE = "flex flex-wrap items-center gap-2 min-w-0 shrink-0 lg:flex-nowrap lg:flex-[0_0_auto] lg:justify-end";
|
|
|
|
const PANEL_ACTIONS_BASE = "w-full min-w-0 max-lg:flex max-lg:flex-wrap max-lg:items-center max-lg:justify-end max-lg:gap-2 max-lg:pt-2.5 max-lg:mt-0.5 max-lg:border-t max-lg:border-line";
|
|
const PANEL_ACTIONS_INLINE = PANEL_ACTIONS_BASE + " lg:flex lg:ml-auto lg:shrink-0 lg:flex-[0_0_auto] lg:self-end lg:w-auto lg:border-t-0 lg:pt-0 lg:mt-0";
|
|
const PANEL_ACTIONS_DEFAULT = PANEL_ACTIONS_BASE + " lg:contents";
|
|
// Mirrors a small light-neutral ButtonUI for use as a bare PopoverTrigger
|
|
// <button> (a PopoverTrigger can't wrap a ButtonUI without nesting buttons).
|
|
const CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3";
|
|
// Drag affordance on the right edge of a resizable column header.
|
|
const RESIZE_HANDLE_CLS = "absolute top-0 right-0 h-full w-[6px] cursor-col-resize select-none z-10 hover:bg-neutral-400/40 active:bg-neutral-500/50";
|
|
// Shared metrics so the formula textarea and its highlight overlay align exactly.
|
|
// Sized to match a small form input (h-[30px], p-1, text-sm). Kept to a single
|
|
// horizontally-scrolling line (whitespace-pre + wrap="off") so it's never taller
|
|
// than any other input; the overlay scroll is kept in sync (see syncScroll).
|
|
const FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre";
|
|
const FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink";
|
|
const FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-ink resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden";
|
|
const FORMULA_PLACEHOLDER = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`;
|
|
|
|
|
|
// Mobile: filters collapse behind a toggle; lg+ always shows the filter row inline.
|
|
const FILTERS_TOGGLE = "lg:hidden inline-flex w-full items-center justify-between gap-2 py-1 px-3 text-sm font-normal text-ink bg-surface-muted border border-line-strong rounded-default shadow-xs cursor-pointer hover:bg-surface-raised";
|
|
const FILTERS_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold text-white bg-sky-700 rounded-full";
|
|
const FILTERS_BODY_BASE = "grow flex flex-col gap-3 min-w-0 max-w-full items-stretch overflow-visible lg:items-end max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0 max-lg:[&_.search-fields]:w-full max-lg:[&_.search-fields]:flex-col [&>.quick-date-tags]:w-full";
|
|
const FILTERS_BODY_INLINE = FILTERS_BODY_BASE
|
|
+ " lg:flex lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5"
|
|
+ " lg:[&>.quick-date-tags]:w-full lg:[&>.quick-date-tags]:basis-full"
|
|
+ " lg:[&>.sales-report-toolbar__presets]:w-full lg:[&>.sales-report-toolbar__presets]:basis-full"
|
|
+ " lg:[&>.search-fields]:flex-1 lg:[&>.search-fields]:min-w-0 lg:[&>.sales-report-toolbar__filters]:flex-1 lg:[&>.sales-report-toolbar__filters]:min-w-0";
|
|
const FILTERS_BODY_DESKTOP_FLEX = "lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 lg:[&>*:not(.quick-date-tags)]:w-auto lg:[&_.search-fields]:flex-1 lg:[&_.search-fields]:min-w-0 lg:[&_.search-fields]:w-auto";
|
|
const FILTERS_BODY_ASIDE = "flex flex-col gap-3 max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0";
|
|
|
|
/** Default AutoTable filter row — stacked on mobile, wrapping row on desktop. */
|
|
export const SEARCH_FIELDS = "search-fields flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3 lg:gap-y-2.5 lg:w-auto lg:min-w-0 [&>*]:min-w-0 [&>*]:w-full lg:[&>*]:w-auto lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_9rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:min-w-[8rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[14rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none [&_.search-fields-spacer]:hidden [&_.search-fields-spacer]:basis-full lg:[&_.search-fields-spacer]:block lg:[&_.search-fields-spacer]:flex-1 lg:[&_.search-fields-spacer]:min-w-4 lg:[&_.search-fields-spacer]:basis-auto lg:[&_.search-fields-spacer]:max-w-none lg:[&_.search-fields-spacer]:min-w-[1rem]";
|
|
|
|
/** Pushes toolbar actions to the end when embedded inside a filter row. */
|
|
export const SEARCH_FIELDS_SPACER = "search-fields-spacer hidden lg:block flex-1 min-w-4 basis-full lg:basis-auto lg:min-w-[1rem]";
|
|
|
|
/** Combobox / custom filter control wrapper — pair with FormCombobox fieldWidth="grow". */
|
|
export const AUTOTABLE_FILTER_FIELD = "flex flex-col gap-1 min-w-0 w-full overflow-visible [&>label]:text-xs [&>label]:font-medium [&>label]:text-ink [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";
|
|
|
|
/** Sales report toolbar wrappers (presets row 1, filters + export row 2). */
|
|
export const SALES_REPORT_TOOLBAR = "sales-report-toolbar flex flex-col gap-2.5 w-full min-w-0 lg:contents";
|
|
export const SALES_REPORT_PRESETS = "sales-report-toolbar__presets min-w-0 w-full [&_.quick-date-tags]:gap-1.5";
|
|
export const SALES_REPORT_FILTERS = "search-fields sales-report-toolbar__filters flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5 lg:w-auto lg:min-w-0 min-w-0 pt-0.5 border-t border-line [&>*]:min-w-0 [&>*]:max-lg:w-full lg:[&>*]:w-auto lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_8rem] lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[12rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";
|
|
|
|
export const AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 w-full [&>label]:text-xs [&>label]:font-medium [&>label]:text-ink [&_.relative]:w-full [&_.relative]:min-w-0";
|
|
export const AUTOTABLE_SEARCH_FIELD_WIDE = AUTOTABLE_SEARCH_FIELD + " lg:flex-[2_1_12rem] lg:max-w-[20rem]";
|
|
export const AUTOTABLE_SEARCH_FIELD_NARROW = AUTOTABLE_SEARCH_FIELD + " lg:flex-[0_1_auto] lg:max-w-[11rem] lg:min-w-[7rem]";
|
|
export const AUTOTABLE_DATE_SEARCH_FIELD = AUTOTABLE_SEARCH_FIELD + " lg:flex-[1_1_10rem] lg:max-w-none";
|
|
|
|
export const SALES_REPORT_FILTER_FIELD = AUTOTABLE_FILTER_FIELD + " sales-report-filter-field lg:flex-[1_1_8rem] lg:max-w-[12rem]";
|
|
export const SALES_REPORT_FILTER_FIELD_WIDE = SALES_REPORT_FILTER_FIELD + " sales-report-filter-field--wide lg:flex-[2_1_10rem] lg:max-w-[16rem]";
|
|
export const SALES_REPORT_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-date lg:flex-[0_1_auto] lg:w-[9rem] lg:min-w-[8.5rem] lg:max-w-[9.5rem]";
|
|
export const SALES_REPORT_FILTER_SEARCH_WIDE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-search--wide lg:flex-[2_1_10rem] lg:max-w-[12rem] lg:min-w-[8rem]";
|
|
export const SALES_REPORT_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-ref lg:flex-[0_1_auto] lg:w-[4.5rem] lg:min-w-[4rem] lg:max-w-[5rem]";
|
|
|
|
/** Sales list (/app/manager/sales) — compact date + car # filters. */
|
|
export const SALES_LIST_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-date lg:flex-[0_1_auto] lg:w-[8.25rem] lg:min-w-[8rem] lg:max-w-[8.75rem]";
|
|
export const SALES_LIST_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-ref lg:flex-[0_1_auto] lg:w-[3.75rem] lg:min-w-[3.5rem] lg:max-w-[4.25rem]";
|
|
|
|
// Side filter panel (opts.searchAside): a bordered card to the left of the table.
|
|
const SEARCH_CARD = "w-64 shrink-0 bg-surface border border-line-strong rounded-default shadow-sm p-4 max-lg:w-full";
|
|
|
|
/** Groups AutoTable filter controls — stacked on mobile, horizontal on desktop. */
|
|
export function AutoTableFilterFields(props: { children?: JSXElement; class?: string }) {
|
|
const cls = () => props.class || SEARCH_FIELDS;
|
|
return <div class={cls()}>{props.children}</div>;
|
|
}
|
|
|
|
const PAGE_NUM_KEY = "page_num";
|
|
const ORDER_BY_KEY = "order_by";
|
|
const ITEMS_PER_PAGE_KEY = "items_per_page";
|
|
const SEARCH_KEY_PREFIX = "search_";
|
|
|
|
export function buildQueryString(filter: AutoTableFilter): string {
|
|
const params = new URLSearchParams();
|
|
params.set(PAGE_NUM_KEY, filter.Pagination.CurrentPage.toString());
|
|
params.set(ITEMS_PER_PAGE_KEY, filter.Pagination.MaxItemsPerPage.toString());
|
|
if (filter.OrderBy.Identifier) {
|
|
params.set(ORDER_BY_KEY, filter.OrderBy.Identifier);
|
|
params.set("order_desc", filter.OrderBy.Descending.toString());
|
|
}
|
|
for (const search of filter.Search) {
|
|
for (const value of search.Values) {
|
|
if (value) {
|
|
params.append(SEARCH_KEY_PREFIX + search.Identifier, value);
|
|
}
|
|
}
|
|
}
|
|
return params.toString();
|
|
}
|
|
|
|
// Parse a row value into a number, tolerating formatted strings like "$1,234.56"
|
|
// or "12%". Returns NaN when there's nothing numeric to read.
|
|
function toCalcNumber(value: any): number {
|
|
if (typeof value === "number") return value;
|
|
if (value === null || value === undefined) return NaN;
|
|
const cleaned = String(value).replace(/[$,%\s]/g, "");
|
|
if (cleaned === "") return NaN;
|
|
return parseFloat(cleaned);
|
|
}
|
|
|
|
// Apply a predefined function to a list of operand numbers. Aggregates
|
|
// (sum/average/median/mode/min/max/count) ignore NaN operands; arithmetic
|
|
// (subtract/multiply/divide) yields NaN if any operand is NaN.
|
|
function applyCalcFunction(fn: CalculatedFunction, operands: number[]): number {
|
|
if (fn === "count") return operands.filter(n => !Number.isNaN(n)).length;
|
|
if (operands.length === 0) return NaN;
|
|
switch (fn) {
|
|
case "sum":
|
|
case "average":
|
|
case "min":
|
|
case "max": {
|
|
const nums = operands.filter(n => !Number.isNaN(n));
|
|
if (nums.length === 0) return NaN;
|
|
if (fn === "sum") return nums.reduce((a, b) => a + b, 0);
|
|
if (fn === "average") return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
if (fn === "min") return Math.min(...nums);
|
|
return Math.max(...nums);
|
|
}
|
|
case "median": {
|
|
const nums = operands.filter(n => !Number.isNaN(n)).sort((a, b) => a - b);
|
|
if (nums.length === 0) return NaN;
|
|
const mid = Math.floor(nums.length / 2);
|
|
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
|
|
}
|
|
case "mode": {
|
|
const nums = operands.filter(n => !Number.isNaN(n));
|
|
if (nums.length === 0) return NaN;
|
|
const counts = new Map<number, number>();
|
|
let best = NaN, bestCount = 0;
|
|
for (const n of nums) {
|
|
const c = (counts.get(n) ?? 0) + 1;
|
|
counts.set(n, c);
|
|
if (c > bestCount) { bestCount = c; best = n; }
|
|
}
|
|
return bestCount > 1 ? best : NaN; // no repeated value -> no mode
|
|
}
|
|
case "subtract":
|
|
if (operands.some(n => Number.isNaN(n))) return NaN;
|
|
return operands.reduce((a, b) => a - b);
|
|
case "multiply":
|
|
if (operands.some(n => Number.isNaN(n))) return NaN;
|
|
return operands.reduce((a, b) => a * b, 1);
|
|
case "divide":
|
|
if (operands.some(n => Number.isNaN(n))) return NaN;
|
|
return operands.reduce((a, b) => b === 0 ? NaN : a / b);
|
|
default:
|
|
return NaN;
|
|
}
|
|
}
|
|
|
|
// -- Excel-style formula support (the "Custom" calculated-column function) ----
|
|
// A small, dependency-free expression evaluator. Supports + - * / ^, comparisons
|
|
// (= <> < > <= >=), modulo (%), unary minus, parentheses, numeric literals,
|
|
// references, and a fixed set of functions (SUM, AVERAGE/AVG, MIN, MAX, COUNT, IF,
|
|
// ABS, ROUND, FLOOR, CEILING/CEIL, SQRT, POWER, MOD, AND, OR, NOT, ROW).
|
|
//
|
|
// Reference kinds let formulas span both columns and rows:
|
|
// [Name] -> the current row's value for that column (a scalar)
|
|
// {Name} -> that column's values across all (filtered) rows (an array)
|
|
// {Name:n} -> the nth (1-based) row of that column (a scalar)
|
|
// {Name:a:b} -> rows a..b inclusive (an array; Excel-style ":" range)
|
|
// e.g. [Revenue] / SUM({Revenue}) * 100 (% of column total)
|
|
// [Score] - AVERAGE({Score}) (deviation from the column mean)
|
|
// SUM({Revenue:1:ROW()}) (running total to the current row)
|
|
// AVERAGE({Sales:ROW()-2:ROW()}) (trailing 3-row moving average)
|
|
// Indices/bounds can be any expression; ROW() is the current row's number, so
|
|
// {Revenue:ROW()-1} is the previous row's revenue and {Revenue:1} the first.
|
|
// Aggregate functions (SUM/AVERAGE/MIN/MAX/COUNT) flatten array arguments;
|
|
// arithmetic and comparison operators are scalar-only (an array operand resolves
|
|
// to NaN). Comparisons yield 1/0; IF/AND/OR/NOT treat any nonzero, non-NaN value
|
|
// as true. compileFormula parses once and throws on a malformed formula. A
|
|
// compiled node is evaluated against a context: cell(name) gives the current-row
|
|
// scalar, column(name) gives the column across rows, row() the current row #. -mta
|
|
|
|
type FormulaValue = number | number[];
|
|
interface FormulaContext {
|
|
cell: (name: string) => number;
|
|
column: (name: string) => number[];
|
|
row: () => number;
|
|
}
|
|
type FormulaNode = (ctx: FormulaContext) => FormulaValue;
|
|
interface FormulaToken { t: string; v: string; }
|
|
|
|
const toScalar = (v: FormulaValue): number => Array.isArray(v) ? NaN : v;
|
|
const flattenValues = (vals: FormulaValue[]): number[] => {
|
|
const out: number[] = [];
|
|
for (const v of vals) {
|
|
if (Array.isArray(v)) { for (const n of v) out.push(n); }
|
|
else out.push(v);
|
|
}
|
|
return out;
|
|
};
|
|
// Index of the first ":" at brace/bracket/paren depth 0, or -1. Lets a column
|
|
// ref's name and its row index/range be split without tripping on a ":" inside a
|
|
// nested {ref} or function args.
|
|
const topLevelColon = (s: string): number => {
|
|
let depth = 0;
|
|
for (let k = 0; k < s.length; k++) {
|
|
const c = s[k];
|
|
if (c === "{" || c === "[" || c === "(") depth++;
|
|
else if (c === "}" || c === "]" || c === ")") depth--;
|
|
else if (c === ":" && depth === 0) return k;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
function tokenizeFormula(src: string): FormulaToken[] {
|
|
const tokens: FormulaToken[] = [];
|
|
const isDigit = (c: string) => c >= "0" && c <= "9";
|
|
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
|
|
if (c === "[") {
|
|
const end = src.indexOf("]", i + 1);
|
|
if (end < 0) throw new Error("Unclosed '[' reference");
|
|
tokens.push({ t: "ref", v: src.slice(i + 1, end).trim() });
|
|
i = end + 1;
|
|
continue;
|
|
}
|
|
if (c === "{") {
|
|
// Balanced-brace scan so a nested {Col:...} inside the index is kept
|
|
// intact (e.g. {A:{B:1}}).
|
|
let depth = 1, j = i + 1;
|
|
while (j < src.length && depth > 0) {
|
|
if (src[j] === "{") depth++;
|
|
else if (src[j] === "}") { depth--; if (depth === 0) break; }
|
|
j++;
|
|
}
|
|
if (depth !== 0) throw new Error("Unclosed '{' column reference");
|
|
tokens.push({ t: "colref", v: src.slice(i + 1, j).trim() });
|
|
i = j + 1;
|
|
continue;
|
|
}
|
|
if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
|
|
let j = i + 1;
|
|
while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
|
|
tokens.push({ t: "num", v: src.slice(i, j) });
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (isAlpha(c)) {
|
|
let j = i + 1;
|
|
while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
|
|
tokens.push({ t: "id", v: src.slice(i, j) });
|
|
i = j;
|
|
continue;
|
|
}
|
|
const two = src.slice(i, i + 2);
|
|
if (two === "<=" || two === ">=" || two === "<>") { tokens.push({ t: "op", v: two }); i += 2; continue; }
|
|
if ("+-*/^%=<>".includes(c)) { tokens.push({ t: "op", v: c }); i++; continue; }
|
|
if ("(),".includes(c)) { tokens.push({ t: "punc", v: c }); i++; continue; }
|
|
throw new Error("Unexpected character '" + c + "'");
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function makeFormulaFunction(name: string, args: FormulaNode[]): FormulaNode {
|
|
// Aggregate args flatten arrays (so {Col} spans rows); scalar args coerce.
|
|
const aggArgs = (ctx: FormulaContext) => flattenValues(args.map(a => a(ctx)));
|
|
const scalarArgs = (ctx: FormulaContext) => args.map(a => toScalar(a(ctx)));
|
|
const arg = (idx: number, ctx: FormulaContext) => args[idx] ? toScalar(args[idx](ctx)) : NaN;
|
|
const truthy = (n: number) => !Number.isNaN(n) && n !== 0;
|
|
switch (name) {
|
|
case "SUM": return (ctx) => applyCalcFunction("sum", aggArgs(ctx));
|
|
case "AVERAGE": case "AVG": return (ctx) => applyCalcFunction("average", aggArgs(ctx));
|
|
case "MEDIAN": return (ctx) => applyCalcFunction("median", aggArgs(ctx));
|
|
case "MODE": return (ctx) => applyCalcFunction("mode", aggArgs(ctx));
|
|
case "MIN": return (ctx) => applyCalcFunction("min", aggArgs(ctx));
|
|
case "MAX": return (ctx) => applyCalcFunction("max", aggArgs(ctx));
|
|
case "COUNT": return (ctx) => applyCalcFunction("count", aggArgs(ctx));
|
|
case "ABS": return (ctx) => Math.abs(arg(0, ctx));
|
|
case "ROUND": return (ctx) => { const f = Math.pow(10, args[1] ? arg(1, ctx) : 0); return Math.round(arg(0, ctx) * f) / f; };
|
|
case "FLOOR": return (ctx) => Math.floor(arg(0, ctx));
|
|
case "CEILING": case "CEIL": return (ctx) => Math.ceil(arg(0, ctx));
|
|
case "SQRT": return (ctx) => Math.sqrt(arg(0, ctx));
|
|
case "POWER": return (ctx) => Math.pow(arg(0, ctx), arg(1, ctx));
|
|
case "MOD": return (ctx) => { const b = arg(1, ctx); return b === 0 ? NaN : arg(0, ctx) % b; };
|
|
case "EXP": return (ctx) => Math.exp(arg(0, ctx));
|
|
case "LN": return (ctx) => Math.log(arg(0, ctx));
|
|
// LOG(n, [base]) — base defaults to 10, matching Excel.
|
|
case "LOG": return (ctx) => Math.log(arg(0, ctx)) / Math.log(args[1] ? arg(1, ctx) : 10);
|
|
// Trigonometry (angles in radians, like Excel; use RADIANS()/DEGREES() to convert).
|
|
case "SIN": return (ctx) => Math.sin(arg(0, ctx));
|
|
case "COS": return (ctx) => Math.cos(arg(0, ctx));
|
|
case "TAN": return (ctx) => Math.tan(arg(0, ctx));
|
|
case "ASIN": return (ctx) => Math.asin(arg(0, ctx));
|
|
case "ACOS": return (ctx) => Math.acos(arg(0, ctx));
|
|
case "ATAN": return (ctx) => Math.atan(arg(0, ctx));
|
|
// ATAN2(x, y) — Excel order (angle of the point (x, y)).
|
|
case "ATAN2": return (ctx) => Math.atan2(arg(1, ctx), arg(0, ctx));
|
|
case "SINH": return (ctx) => Math.sinh(arg(0, ctx));
|
|
case "COSH": return (ctx) => Math.cosh(arg(0, ctx));
|
|
case "TANH": return (ctx) => Math.tanh(arg(0, ctx));
|
|
case "PI": return () => Math.PI;
|
|
case "RADIANS": return (ctx) => arg(0, ctx) * Math.PI / 180;
|
|
case "DEGREES": return (ctx) => arg(0, ctx) * 180 / Math.PI;
|
|
case "IF": return (ctx) => truthy(arg(0, ctx)) ? arg(1, ctx) : (args[2] ? arg(2, ctx) : 0);
|
|
case "AND": return (ctx) => scalarArgs(ctx).every(truthy) ? 1 : 0;
|
|
case "OR": return (ctx) => scalarArgs(ctx).some(truthy) ? 1 : 0;
|
|
case "NOT": return (ctx) => truthy(arg(0, ctx)) ? 0 : 1;
|
|
// ROW(): the current row's 1-based position in the filtered set.
|
|
case "ROW": return (ctx) => ctx.row();
|
|
default: throw new Error("Unknown function '" + name + "'");
|
|
}
|
|
}
|
|
|
|
export function compileFormula(src: string): FormulaNode {
|
|
const tokens = tokenizeFormula(src);
|
|
if (tokens.length === 0) throw new Error("Empty formula");
|
|
let pos = 0;
|
|
const peek = () => tokens[pos];
|
|
const next = () => tokens[pos++];
|
|
const expect = (v: string) => { const t = next(); if (!t || t.v !== v) throw new Error("Expected '" + v + "'"); };
|
|
const isOp = (...ops: string[]) => peek() && peek().t === "op" && ops.includes(peek().v);
|
|
|
|
const parseComparison = (): FormulaNode => {
|
|
let left = parseAddSub();
|
|
while (isOp("=", "<>", "<", ">", "<=", ">=")) {
|
|
const op = next().v, l = left, r = parseAddSub();
|
|
left = (ctx) => {
|
|
const a = toScalar(l(ctx)), b = toScalar(r(ctx));
|
|
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
|
|
switch (op) {
|
|
case "=": return a === b ? 1 : 0;
|
|
case "<>": return a !== b ? 1 : 0;
|
|
case "<": return a < b ? 1 : 0;
|
|
case ">": return a > b ? 1 : 0;
|
|
case "<=": return a <= b ? 1 : 0;
|
|
default: return a >= b ? 1 : 0;
|
|
}
|
|
};
|
|
}
|
|
return left;
|
|
};
|
|
const parseAddSub = (): FormulaNode => {
|
|
let left = parseMulDiv();
|
|
while (isOp("+", "-")) {
|
|
const op = next().v, l = left, r = parseMulDiv();
|
|
left = (ctx) => op === "+" ? toScalar(l(ctx)) + toScalar(r(ctx)) : toScalar(l(ctx)) - toScalar(r(ctx));
|
|
}
|
|
return left;
|
|
};
|
|
const parseMulDiv = (): FormulaNode => {
|
|
let left = parsePow();
|
|
while (isOp("*", "/", "%")) {
|
|
const op = next().v, l = left, r = parsePow();
|
|
left = (ctx) => {
|
|
const a = toScalar(l(ctx)), b = toScalar(r(ctx));
|
|
if (op === "*") return a * b;
|
|
return b === 0 ? NaN : (op === "/" ? a / b : a % b); // "%" = remainder
|
|
};
|
|
}
|
|
return left;
|
|
};
|
|
const parsePow = (): FormulaNode => {
|
|
const left = parseUnary();
|
|
if (isOp("^")) { next(); const r = parsePow(); return (ctx) => Math.pow(toScalar(left(ctx)), toScalar(r(ctx))); }
|
|
return left;
|
|
};
|
|
const parseUnary = (): FormulaNode => {
|
|
if (isOp("-")) { next(); const o = parseUnary(); return (ctx) => -toScalar(o(ctx)); }
|
|
if (isOp("+")) { next(); return parseUnary(); }
|
|
return parsePrimary();
|
|
};
|
|
const parsePrimary = (): FormulaNode => {
|
|
const t = peek();
|
|
if (!t) throw new Error("Unexpected end of formula");
|
|
if (t.t === "num") { next(); const v = parseFloat(t.v); return () => v; }
|
|
if (t.t === "ref") { next(); const name = t.v; return (ctx) => ctx.cell(name); }
|
|
if (t.t === "colref") {
|
|
next();
|
|
const raw = t.v;
|
|
// {Name} -> whole column (array); {Name:n} -> the nth (1-based) row
|
|
// (scalar); {Name:a:b} -> rows a..b inclusive (array, Excel range).
|
|
const ci = topLevelColon(raw);
|
|
if (ci < 0) { const name = raw.trim(); return (ctx) => ctx.column(name); }
|
|
const name = raw.slice(0, ci).trim();
|
|
const spec = raw.slice(ci + 1).trim();
|
|
const ri = topLevelColon(spec);
|
|
if (ri < 0) {
|
|
const idxNode = compileFormula(spec);
|
|
return (ctx) => {
|
|
const arr = ctx.column(name);
|
|
const n = toScalar(idxNode(ctx));
|
|
if (Number.isNaN(n)) return NaN;
|
|
const idx = Math.trunc(n) - 1;
|
|
return idx >= 0 && idx < arr.length ? arr[idx] : NaN;
|
|
};
|
|
}
|
|
const startNode = compileFormula(spec.slice(0, ri).trim());
|
|
const endNode = compileFormula(spec.slice(ri + 1).trim());
|
|
return (ctx) => {
|
|
const arr = ctx.column(name);
|
|
let s = toScalar(startNode(ctx)), e = toScalar(endNode(ctx));
|
|
if (Number.isNaN(s) || Number.isNaN(e)) return [];
|
|
s = Math.trunc(s); e = Math.trunc(e);
|
|
if (s > e) { const tmp = s; s = e; e = tmp; }
|
|
const out: number[] = [];
|
|
for (let k = s; k <= e; k++) { const i = k - 1; if (i >= 0 && i < arr.length) out.push(arr[i]); }
|
|
return out;
|
|
};
|
|
}
|
|
if (t.t === "punc" && t.v === "(") { next(); const e = parseComparison(); expect(")"); return e; }
|
|
if (t.t === "id") {
|
|
next();
|
|
// A bare identifier (no following "(") is a named constant (PI, E, …).
|
|
if (!(peek() && peek().t === "punc" && peek().v === "(")) {
|
|
const cv = FORMULA_CONSTANTS[t.v.toUpperCase()];
|
|
if (cv === undefined) throw new Error("Unknown name '" + t.v + "'");
|
|
return () => cv;
|
|
}
|
|
expect("(");
|
|
const args: FormulaNode[] = [];
|
|
if (!(peek() && peek().t === "punc" && peek().v === ")")) {
|
|
args.push(parseComparison());
|
|
while (peek() && peek().t === "punc" && peek().v === ",") { next(); args.push(parseComparison()); }
|
|
}
|
|
expect(")");
|
|
return makeFormulaFunction(t.v.toUpperCase(), args);
|
|
}
|
|
throw new Error("Unexpected token '" + t.v + "'");
|
|
};
|
|
|
|
const root = parseComparison();
|
|
if (pos < tokens.length) throw new Error("Unexpected token '" + tokens[pos].v + "'");
|
|
return root;
|
|
}
|
|
|
|
const escapeHtml = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
// Lexical syntax highlighter for the formula editor overlay. Returns HTML with
|
|
// colored spans for cell refs [..], column refs {..}, function names, numbers,
|
|
// and operators. Purely visual — mirrors the tokenizer's character rules.
|
|
function highlightFormula(src: string): string {
|
|
const isDigit = (c: string) => c >= "0" && c <= "9";
|
|
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
|
|
let out = "";
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
if (c === "[") {
|
|
const end = src.indexOf("]", i + 1);
|
|
const j = end < 0 ? src.length : end + 1;
|
|
out += `<span class="text-sky-600 dark:text-sky-400">${escapeHtml(src.slice(i, j))}</span>`;
|
|
i = j; continue;
|
|
}
|
|
if (c === "{") {
|
|
let depth = 1, j = i + 1;
|
|
while (j < src.length && depth > 0) { if (src[j] === "{") depth++; else if (src[j] === "}") depth--; j++; }
|
|
out += `<span class="text-violet-600">${escapeHtml(src.slice(i, j))}</span>`;
|
|
i = j; continue;
|
|
}
|
|
if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
|
|
let j = i + 1;
|
|
while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
|
|
out += `<span class="text-amber-600 dark:text-amber-400">${escapeHtml(src.slice(i, j))}</span>`;
|
|
i = j; continue;
|
|
}
|
|
if (isAlpha(c)) {
|
|
let j = i + 1;
|
|
while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
|
|
const word = src.slice(i, j);
|
|
let k = j; while (k < src.length && src[k] === " ") k++;
|
|
out += src[k] === "("
|
|
? `<span class="text-emerald-700 dark:text-emerald-400 font-semibold">${escapeHtml(word)}</span>`
|
|
: (FORMULA_CONSTANTS[word.toUpperCase()] !== undefined ? `<span class="text-amber-600 dark:text-amber-400">${escapeHtml(word)}</span>` : escapeHtml(word));
|
|
i = j; continue;
|
|
}
|
|
if ("+-*/^%=<>(),:".includes(c)) { out += `<span class="text-ink-faint">${escapeHtml(c)}</span>`; i++; continue; }
|
|
out += escapeHtml(c); i++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Format a numeric result per its data type. Falls back to plain string when no
|
|
// data type is set. prefix/suffix wrap the formatted text.
|
|
function formatCalcResult(result: number, dataType: CalculatedDataType | undefined, precision: number | undefined, prefix: string | undefined, suffix: string | undefined, emptyValue: string | undefined): string {
|
|
if (!isFinite(result)) return emptyValue ?? "—";
|
|
let money = "";
|
|
let percent = "";
|
|
let text: string;
|
|
switch (dataType) {
|
|
case "money": money = "$"; text = formatDecimal(result, precision ?? 2); break;
|
|
case "decimal": text = formatDecimal(result, precision ?? 2); break;
|
|
case "integer": text = formatNumber(Math.round(result)); break;
|
|
case "percent": percent = "%"; text = result.toFixed(precision ?? 2); break;
|
|
case "number": text = typeof precision === "number" ? formatDecimal(result, precision) : formatNumber(result); break;
|
|
case "plain":
|
|
default: text = typeof precision === "number" ? result.toFixed(precision) : String(result);
|
|
}
|
|
// Never render a negative zero: once rounded to the display precision, a tiny
|
|
// negative like SIN(2*PI) ≈ -2.4e-16 would otherwise show as "-0"/"-0.00".
|
|
if (text.charAt(0) === "-" && parseFloat(text) === 0) text = text.slice(1);
|
|
return (prefix ?? "") + money + text + percent + (suffix ?? "");
|
|
}
|
|
|
|
// Evaluate a calculated column for a single row. A spec with a `compute` closure
|
|
// (user columns) defers to it; otherwise operands are read from row fields and
|
|
// combined by the predefined function. Returns NaN when not computable.
|
|
export function computeCalculatedValue(item: any, spec: CalculatedColumnSpec): number {
|
|
if (spec.compute) return spec.compute(item);
|
|
if (spec.fn === "custom") return NaN; // custom requires a `compute` closure
|
|
const operands = spec.operands.map(op => typeof op === "number" ? op : toCalcNumber(item?.[op]));
|
|
return applyCalcFunction(spec.fn, operands);
|
|
}
|
|
|
|
// Compute and format a calculated column's value for display/CSV/PDF.
|
|
export function formatCalculatedValue(item: any, spec: CalculatedColumnSpec): any {
|
|
const result = computeCalculatedValue(item, spec);
|
|
if (spec.format) return spec.format(result, item);
|
|
return formatCalcResult(result, spec.dataType, spec.precision, spec.prefix, spec.suffix, spec.emptyValue);
|
|
}
|
|
|
|
interface ProcessedData {
|
|
data: any[];
|
|
allFilteredData: any[];
|
|
pagination: AutoTablePagination;
|
|
}
|
|
|
|
// Apply the search/filter entries to raw rows (no sort/pagination). Shared by
|
|
// processDataLocally and the cross-row aggregate source so both see the same set.
|
|
function applySearchFilters(rawData: any[], searchEntries: AutoTableSearchEntry[]): any[] {
|
|
let processed = [...rawData];
|
|
|
|
for (const search of searchEntries) {
|
|
if (search.Values && search.Values.length > 0 && search.Values[0]) {
|
|
if (search.Values.length > 1) {
|
|
const matchSet = new Set(search.Values.map(v => String(v).toLowerCase()));
|
|
processed = processed.filter(item => {
|
|
const fieldValue = item[search.Identifier];
|
|
if (fieldValue === null || fieldValue === undefined) return false;
|
|
return matchSet.has(String(fieldValue).toLowerCase());
|
|
});
|
|
} else {
|
|
const searchValue = search.Values[0].toLowerCase();
|
|
const match = search.Exact
|
|
? (val: any) => String(val).toLowerCase() === searchValue
|
|
: (val: any) => String(val).toLowerCase().includes(searchValue);
|
|
|
|
if (isMultiSearchIdentifier(search.Identifier)) {
|
|
const fields = parseMultiSearchFields(search.Identifier);
|
|
processed = processed.filter(item => {
|
|
return fields.some(field => {
|
|
const fieldValue = item[field];
|
|
if (fieldValue === null || fieldValue === undefined) return false;
|
|
return match(fieldValue);
|
|
});
|
|
});
|
|
} else {
|
|
processed = processed.filter(item => {
|
|
const fieldValue = item[search.Identifier];
|
|
if (fieldValue === null || fieldValue === undefined) return false;
|
|
return match(fieldValue);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
function processDataLocally(rawData: any[], filter: AutoTableFilter, columns: AutoTableColumn[]): ProcessedData {
|
|
let processed = applySearchFilters(rawData, filter.Search);
|
|
|
|
const totalItems = processed.length;
|
|
|
|
if (filter.OrderBy.Identifier) {
|
|
let sortKey: string | null = null;
|
|
let column: AutoTableColumn | undefined;
|
|
const colIndexMatch = filter.OrderBy.Identifier.match(/^_col_(\d+)$/);
|
|
if (colIndexMatch) {
|
|
const colIndex = parseInt(colIndexMatch[1], 10);
|
|
column = columns[colIndex];
|
|
if (column?.sortIdentifier) {
|
|
sortKey = column.sortIdentifier;
|
|
} else if (!column?.calculated && processed.length > 0) {
|
|
// Calculated columns derive their sort value from the spec below,
|
|
// not from a row field, so skip the field-key derivation for them.
|
|
const keys = Object.keys(processed[0]);
|
|
if (colIndex < keys.length) {
|
|
sortKey = keys[colIndex];
|
|
}
|
|
}
|
|
} else {
|
|
sortKey = filter.OrderBy.Identifier;
|
|
column = columns.find((c) => c.sortIdentifier === sortKey);
|
|
}
|
|
|
|
if (sortKey || column?.calculated) {
|
|
const finalSortKey = sortKey;
|
|
// Calculated columns sort by their computed number (NaN sorts last
|
|
// via the null handling below) unless an explicit sortValue is given.
|
|
const calcSpec = column?.calculated;
|
|
const sortValue = column?.sortValue
|
|
?? (calcSpec ? (r: any) => { const n = computeCalculatedValue(r, calcSpec); return Number.isNaN(n) ? null : n; } : undefined);
|
|
// Extract the comparison value into a stable key so the column's
|
|
// sortType (e.g. "numeric") and any custom sortValue
|
|
// drive the order via the shared comparator. Falling back to the raw
|
|
// field keeps the prior type-aware behavior for untyped columns.
|
|
const getVal = typeof sortValue === "function" ? sortValue : (r: any) => r[finalSortKey];
|
|
const sortType = column?.sortType;
|
|
processed.sort((a, b) => {
|
|
const av = getVal(a);
|
|
const bv = getVal(b);
|
|
// Empty/null always sort last, regardless of direction (matches
|
|
// the prior behavior and the CellGrid comparator).
|
|
if (av === null || av === undefined) return 1;
|
|
if (bv === null || bv === undefined) return -1;
|
|
let comparison: number;
|
|
if (!sortType && typeof av === "number" && typeof bv === "number") {
|
|
comparison = av - bv;
|
|
} else if (!sortType && av instanceof Date && bv instanceof Date) {
|
|
comparison = av.getTime() - bv.getTime();
|
|
} else {
|
|
comparison = compareRowsGeneric({ v: av }, { v: bv }, "v", sortType);
|
|
}
|
|
return filter.OrderBy.Descending ? -comparison : comparison;
|
|
});
|
|
}
|
|
}
|
|
|
|
const maxItems = filter.Pagination.MaxItemsPerPage;
|
|
const totalPages = maxItems === -1 ? 1 : Math.max(1, Math.ceil(totalItems / maxItems));
|
|
const currentPage = Math.min(filter.Pagination.CurrentPage, totalPages);
|
|
|
|
let paginatedData: any[];
|
|
let viewRangeLower: number, viewRangeUpper: number;
|
|
if (maxItems === -1) {
|
|
paginatedData = processed;
|
|
viewRangeLower = totalItems > 0 ? 1 : 0;
|
|
viewRangeUpper = totalItems;
|
|
} else {
|
|
const startIdx = (currentPage - 1) * maxItems;
|
|
const endIdx = startIdx + maxItems;
|
|
paginatedData = processed.slice(startIdx, endIdx);
|
|
viewRangeLower = totalItems > 0 ? startIdx + 1 : 0;
|
|
viewRangeUpper = Math.min(endIdx, totalItems);
|
|
}
|
|
|
|
return {
|
|
data: paginatedData,
|
|
allFilteredData: processed,
|
|
pagination: {
|
|
CurrentPage: currentPage,
|
|
TotalPages: totalPages,
|
|
TotalItems: totalItems,
|
|
MaxItemsPerPage: maxItems,
|
|
ViewRangeLower: viewRangeLower,
|
|
ViewRangeUpper: viewRangeUpper,
|
|
},
|
|
};
|
|
}
|
|
|
|
function downloadCSV(data: any[], columns: AutoTableColumn[], filename: string): void {
|
|
if (data.length === 0) return;
|
|
|
|
const csvColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
|
|
if (csvColumns.length === 0) return;
|
|
|
|
const escapeCSV = (value: any): string => {
|
|
if (value === null || value === undefined) return "";
|
|
const str = String(value);
|
|
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
|
return '"' + str.replace(/"/g, '""') + '"';
|
|
}
|
|
return str;
|
|
};
|
|
|
|
const cellValue = (col: AutoTableColumn, item: any): any =>
|
|
col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);
|
|
|
|
const headers = csvColumns.map(col => escapeCSV(col.displayName)).join(",");
|
|
const rows = data.map(item => {
|
|
return csvColumns.map(col => escapeCSV(cellValue(col, item))).join(",");
|
|
});
|
|
|
|
const csvContent = [headers, ...rows].join("\n");
|
|
|
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename + ".csv";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function buildTablePDF(data: any[], columns: AutoTableColumn[], pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []) {
|
|
const pdfColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
|
|
if (data.length === 0 || pdfColumns.length === 0) return null;
|
|
|
|
const pdfCellValue = (col: AutoTableColumn, item: any): any =>
|
|
col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);
|
|
|
|
const header = {
|
|
title: "",
|
|
subtitle: "",
|
|
showDate: true,
|
|
logoUrl: "/images/logo_black.png",
|
|
showLogo: true,
|
|
orientation: PDF_ORIENTATION_LANDSCAPE,
|
|
...pdfHeader,
|
|
};
|
|
|
|
const pdf = await PDFDocument.create();
|
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
|
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
|
const fontHeading = fontBold;
|
|
|
|
const PAGE_MARGIN = 40;
|
|
const ROW_HEIGHT = 18;
|
|
const COL_HEADER_HEIGHT = 22;
|
|
const FONT_SIZE = 8;
|
|
const COL_HEADER_FONT_SIZE = 9;
|
|
const isPortrait = header.orientation === PDF_ORIENTATION_PORTRAIT;
|
|
const PAGE_WIDTH = isPortrait ? 612 : 792;
|
|
const PAGE_HEIGHT = isPortrait ? 792 : 612;
|
|
const CONTENT_WIDTH = PAGE_WIDTH - PAGE_MARGIN * 2;
|
|
const HEADER_BG = rgb(1, 1, 1);
|
|
const HEADER_TEXT_COLOR = rgb(0.1, 0.1, 0.1);
|
|
const ROW_ALT_BG = rgb(0.95, 0.95, 0.95);
|
|
const BORDER_COLOR = rgb(0.8, 0.8, 0.8);
|
|
const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
|
|
const MUTED_COLOR = rgb(0.5, 0.5, 0.5);
|
|
|
|
// Embed logo
|
|
let logoImage: any = null;
|
|
if (header.showLogo && header.logoUrl) {
|
|
try {
|
|
const logoBytes = await fetch(header.logoUrl).then((r) => r.arrayBuffer());
|
|
logoImage = await pdf.embedPng(new Uint8Array(logoBytes));
|
|
} catch {
|
|
// Logo not available
|
|
}
|
|
}
|
|
|
|
const colWidths = pdfColumns.map(col => {
|
|
const headerLen = col.displayName.length;
|
|
let maxDataLen = headerLen;
|
|
for (let i = 0; i < Math.min(data.length, 50); i++) {
|
|
const val = pdfCellValue(col, data[i]);
|
|
const len = val === null || val === undefined ? 0 : String(val).length;
|
|
if (len > maxDataLen) maxDataLen = len;
|
|
}
|
|
return Math.max(maxDataLen, 4);
|
|
});
|
|
const totalWeight = colWidths.reduce((sum, w) => sum + w, 0);
|
|
const colActualWidths = colWidths.map(w => (w / totalWeight) * CONTENT_WIDTH);
|
|
|
|
const truncateText = (text: any, maxWidth: number, f: any, size: number): string => {
|
|
if (!text) return "";
|
|
let str = String(text);
|
|
let w = f.widthOfTextAtSize(str, size);
|
|
if (w <= maxWidth) return str;
|
|
while (str.length > 0 && w > maxWidth) {
|
|
str = str.slice(0, -1);
|
|
w = f.widthOfTextAtSize(str + "…", size);
|
|
}
|
|
return str + "…";
|
|
};
|
|
|
|
let page: any = null;
|
|
let y = 0;
|
|
let isFirstPage = true;
|
|
|
|
const drawPageHeader = () => {
|
|
const hasHeader = header.title || header.subtitle || header.showDate;
|
|
if (!isFirstPage || !hasHeader) return;
|
|
|
|
const TITLE_SIZE = 12;
|
|
const SUBTITLE_SIZE = 9;
|
|
const DATE_SIZE = 9;
|
|
|
|
// Right-aligned date (the logo is drawn in the page footer, not here).
|
|
if (header.showDate) {
|
|
const now = new Date();
|
|
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
const dateWidth = font.widthOfTextAtSize(dateStr, DATE_SIZE);
|
|
page.drawText(dateStr, {
|
|
x: PAGE_MARGIN + CONTENT_WIDTH - dateWidth,
|
|
y: y - DATE_SIZE - 2,
|
|
size: DATE_SIZE, font, color: MUTED_COLOR,
|
|
});
|
|
}
|
|
|
|
let rightBlockHeight = header.showDate ? DATE_SIZE + 2 : 0;
|
|
|
|
// Title on the left
|
|
if (header.title) {
|
|
page.drawText(header.title, {
|
|
x: PAGE_MARGIN,
|
|
y: y - TITLE_SIZE,
|
|
size: TITLE_SIZE, font: fontHeading, color: TEXT_COLOR,
|
|
});
|
|
}
|
|
|
|
let blockHeight = Math.max(rightBlockHeight, header.title ? TITLE_SIZE + 6 : 0);
|
|
|
|
let leftBlockHeight = header.title ? TITLE_SIZE + 6 : 0;
|
|
if (header.subtitle) {
|
|
page.drawText(header.subtitle, {
|
|
x: PAGE_MARGIN,
|
|
y: y - leftBlockHeight - SUBTITLE_SIZE - 4,
|
|
size: SUBTITLE_SIZE, font, color: MUTED_COLOR,
|
|
});
|
|
leftBlockHeight += SUBTITLE_SIZE + 4;
|
|
blockHeight = Math.max(blockHeight, leftBlockHeight);
|
|
}
|
|
|
|
if (blockHeight > 0) {
|
|
y -= blockHeight + 16;
|
|
}
|
|
|
|
// Divider line
|
|
page.drawLine({
|
|
start: { x: PAGE_MARGIN, y },
|
|
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
|
|
thickness: 0.75, color: BORDER_COLOR,
|
|
});
|
|
y -= 12;
|
|
};
|
|
|
|
const drawColumnHeaders = () => {
|
|
let x = PAGE_MARGIN;
|
|
for (let i = 0; i < pdfColumns.length; i++) {
|
|
const text = pdfColumns[i].displayName;
|
|
const truncated = truncateText(text, colActualWidths[i] - 6, fontBold, COL_HEADER_FONT_SIZE);
|
|
page.drawText(truncated, {
|
|
x: x + 4, y: y - COL_HEADER_HEIGHT + 7,
|
|
size: COL_HEADER_FONT_SIZE, font: fontBold, color: HEADER_TEXT_COLOR,
|
|
});
|
|
x += colActualWidths[i];
|
|
}
|
|
y -= COL_HEADER_HEIGHT;
|
|
page.drawLine({
|
|
start: { x: PAGE_MARGIN, y },
|
|
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
|
|
thickness: 1, color: rgb(0.15, 0.15, 0.15),
|
|
});
|
|
};
|
|
|
|
const addPage = () => {
|
|
page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
|
y = PAGE_HEIGHT - PAGE_MARGIN;
|
|
drawPageHeader();
|
|
drawColumnHeaders();
|
|
isFirstPage = false;
|
|
};
|
|
|
|
addPage();
|
|
|
|
for (let rowIdx = 0; rowIdx < data.length; rowIdx++) {
|
|
if (y - ROW_HEIGHT < PAGE_MARGIN) {
|
|
addPage();
|
|
}
|
|
|
|
if (rowIdx % 2 === 1) {
|
|
page.drawRectangle({
|
|
x: PAGE_MARGIN, y: y - ROW_HEIGHT,
|
|
width: CONTENT_WIDTH, height: ROW_HEIGHT,
|
|
color: ROW_ALT_BG,
|
|
});
|
|
}
|
|
|
|
page.drawLine({
|
|
start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
|
|
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
|
|
thickness: 0.5, color: BORDER_COLOR,
|
|
});
|
|
|
|
let x = PAGE_MARGIN;
|
|
for (let i = 0; i < pdfColumns.length; i++) {
|
|
const raw = pdfCellValue(pdfColumns[i], data[rowIdx]);
|
|
const text = raw === null || raw === undefined ? "" : String(raw);
|
|
const truncated = truncateText(text, colActualWidths[i] - 6, font, FONT_SIZE);
|
|
page.drawText(truncated, {
|
|
x: x + 4, y: y - ROW_HEIGHT + 6,
|
|
size: FONT_SIZE, font, color: TEXT_COLOR,
|
|
});
|
|
x += colActualWidths[i];
|
|
}
|
|
y -= ROW_HEIGHT;
|
|
}
|
|
|
|
// Footer summary rows (Total, Subtotal, …): a subtle shaded band under a
|
|
// divider, emphasized label and value right-aligned in the last column —
|
|
// mirroring the on-screen <tfoot>.
|
|
if (summaries.length > 0) {
|
|
// Left edge x of each column.
|
|
const colX: number[] = [];
|
|
{ let xx = PAGE_MARGIN; for (let i = 0; i < pdfColumns.length; i++) { colX[i] = xx; xx += colActualWidths[i]; } }
|
|
// Draw text with its right edge at `rightX`; returns the text width.
|
|
const drawRightEdge = (text: string, rightX: number, fnt: any): number => {
|
|
const w = fnt.widthOfTextAtSize(text, FONT_SIZE);
|
|
page.drawText(text, { x: rightX - w, y: y - ROW_HEIGHT + 6, size: FONT_SIZE, font: fnt, color: TEXT_COLOR });
|
|
return w;
|
|
};
|
|
const cellRight = (colIdx: number) => colX[colIdx] + colActualWidths[colIdx] - 4;
|
|
page.drawLine({
|
|
start: { x: PAGE_MARGIN, y },
|
|
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
|
|
thickness: 1, color: rgb(0.55, 0.55, 0.55),
|
|
});
|
|
for (const s of summaries) {
|
|
if (y - ROW_HEIGHT < PAGE_MARGIN) addPage();
|
|
page.drawRectangle({
|
|
x: PAGE_MARGIN, y: y - ROW_HEIGHT,
|
|
width: CONTENT_WIDTH, height: ROW_HEIGHT,
|
|
color: rgb(0.96, 0.96, 0.96),
|
|
});
|
|
page.drawLine({
|
|
start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
|
|
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
|
|
thickness: 0.5, color: BORDER_COLOR,
|
|
});
|
|
// Always right-aligned in the last column; only the label (title) is bold.
|
|
const valueColIdx = pdfColumns.length - 1;
|
|
if (valueColIdx > 0) {
|
|
drawRightEdge(s.value, cellRight(valueColIdx), font); // value in the last column
|
|
drawRightEdge(s.label, cellRight(valueColIdx - 1), fontBold); // label in the cell to the left
|
|
} else {
|
|
// Single-column table: share the one cell (value at the right, label just left of it).
|
|
const right = cellRight(0);
|
|
const vw = drawRightEdge(s.value, right, font);
|
|
drawRightEdge(s.label, right - vw - 8, fontBold);
|
|
}
|
|
y -= ROW_HEIGHT;
|
|
}
|
|
}
|
|
|
|
// Draw belowTable content if provided
|
|
if (header.belowTable) {
|
|
await header.belowTable({
|
|
page,
|
|
y,
|
|
pageWidth: PAGE_WIDTH,
|
|
pageHeight: PAGE_HEIGHT,
|
|
margin: PAGE_MARGIN,
|
|
contentWidth: CONTENT_WIDTH,
|
|
font,
|
|
fontBold,
|
|
colors: { text: TEXT_COLOR, muted: MUTED_COLOR, border: BORDER_COLOR },
|
|
response,
|
|
pdf,
|
|
addPage: () => {
|
|
page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
|
y = PAGE_HEIGHT - PAGE_MARGIN;
|
|
return { page, y };
|
|
},
|
|
});
|
|
}
|
|
|
|
// Logo footer dimensions (drawn bottom-left of every page).
|
|
const FOOTER_LOGO_MAX_HEIGHT = 18;
|
|
const FOOTER_LOGO_MAX_WIDTH = 70;
|
|
let footerLogoWidth = 0;
|
|
let footerLogoHeight = 0;
|
|
if (logoImage) {
|
|
const aspect = logoImage.width / logoImage.height;
|
|
footerLogoHeight = Math.min(FOOTER_LOGO_MAX_HEIGHT, logoImage.height);
|
|
footerLogoWidth = footerLogoHeight * aspect;
|
|
if (footerLogoWidth > FOOTER_LOGO_MAX_WIDTH) {
|
|
footerLogoWidth = FOOTER_LOGO_MAX_WIDTH;
|
|
footerLogoHeight = footerLogoWidth / aspect;
|
|
}
|
|
}
|
|
|
|
const pages = pdf.getPages();
|
|
for (let i = 0; i < pages.length; i++) {
|
|
// Page number bottom-left, logo bottom-right.
|
|
const footerText = "Page " + (i + 1) + " of " + pages.length;
|
|
pages[i].drawText(footerText, {
|
|
x: PAGE_MARGIN,
|
|
y: PAGE_MARGIN - 20,
|
|
size: 8, font, color: MUTED_COLOR,
|
|
});
|
|
if (logoImage) {
|
|
pages[i].drawImage(logoImage, {
|
|
x: PAGE_WIDTH - PAGE_MARGIN - footerLogoWidth,
|
|
y: PAGE_MARGIN - 18 - (footerLogoHeight - 8) / 2,
|
|
width: footerLogoWidth,
|
|
height: footerLogoHeight,
|
|
});
|
|
}
|
|
}
|
|
|
|
return await pdf.save();
|
|
}
|
|
|
|
async function downloadPDF(data: any[], columns: AutoTableColumn[], filename: string, pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []): Promise<void> {
|
|
const pdfBytes = await buildTablePDF(data, columns, pdfHeader, response, summaries);
|
|
if (!pdfBytes) return;
|
|
|
|
const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename + ".pdf";
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
interface FormulaSpec { fn: string; operands: string[]; formula: string; }
|
|
interface FormulaFieldApi { setSpec: (spec: FormulaSpec) => void; }
|
|
interface FormulaFieldProps {
|
|
// "column": Basic functions combine the chosen columns per row (column-based).
|
|
// "summary": Basic functions aggregate one column down the rows (row-based).
|
|
mode?: "column" | "summary";
|
|
onchange: (spec: FormulaSpec) => void; // fired on every edit
|
|
operandOptions: () => FormSelectOption[]; // column choices + caret-name → column mapping
|
|
onHighlight?: (keys: string[] | null) => void; // highlight the affected column(s)
|
|
apiRef?: (api: FormulaFieldApi) => void; // parent uses api.setSpec to seed/reset
|
|
}
|
|
|
|
// The calculation editor, shared by the calculated-column and summary-row forms.
|
|
// Basic mode: a predefined function + column select(s). Advanced mode ("custom"):
|
|
// an Excel-style formula in a single-line textarea (UNCONTROLLED, overlay repainted
|
|
// IMPERATIVELY so typing never loses focus) with a "Function" menu and insert chips.
|
|
// The current state is reported to the parent as a {fn, operands, formula} spec; the
|
|
// parent seeds it through api.setSpec when its popover opens.
|
|
function FormulaField(props: FormulaFieldProps) {
|
|
const ctx = useFloatingContext();
|
|
const [funcMenuOpen, setFuncMenuOpen] = createSignal(false);
|
|
const [funcSearch, setFuncSearch] = createSignal("");
|
|
let funcSearchInput: HTMLInputElement | undefined;
|
|
const [colMenuOpen, setColMenuOpen] = createSignal(false);
|
|
const [colSearch, setColSearch] = createSignal("");
|
|
let colSearchInput: HTMLInputElement | undefined;
|
|
const [constMenuOpen, setConstMenuOpen] = createSignal(false);
|
|
const [constSearch, setConstSearch] = createSignal("");
|
|
let constSearchInput: HTMLInputElement | undefined;
|
|
const [fn, setFn] = createSignal("sum"); // "custom" === advanced
|
|
const [formula, setFormula] = createSignal("");
|
|
const [multiOperands, setMultiOperands] = createSignal([] as string[]);
|
|
const [operandA, setOperandA] = createSignal("");
|
|
const [operandB, setOperandB] = createSignal("");
|
|
const [singleOperand, setSingleOperand] = createSignal("");
|
|
let ta: HTMLTextAreaElement | undefined;
|
|
let overlay: HTMLDivElement | undefined;
|
|
|
|
const mode = () => (typeof props.mode === "function" ? (props.mode as () => string)() : props.mode) || "column";
|
|
const operandOptions = () =>
|
|
typeof props.operandOptions === "function"
|
|
? (props.operandOptions as () => FormSelectOption[])()
|
|
: (props.operandOptions as unknown as FormSelectOption[]) || [];
|
|
const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);
|
|
const isAdvanced = () => fn() === "custom";
|
|
const isBinary = () => isBinaryCalcFn(fn());
|
|
const functionOptions = () => mode() === "summary" ? SUMMARY_FUNCTION_OPTIONS : CALC_FUNCTION_OPTIONS;
|
|
const currentOperands = (): string[] => {
|
|
if (isAdvanced()) return [];
|
|
if (mode() === "summary") return singleOperand() ? [singleOperand()] : [];
|
|
if (isBinary()) return [operandA(), operandB()].filter(Boolean);
|
|
return multiOperands();
|
|
};
|
|
const emit = () => props.onchange({ fn: fn(), operands: currentOperands(), formula: isAdvanced() ? formula() : "" });
|
|
|
|
const renderOverlay = () => { if (overlay && ta) overlay.innerHTML = ta.value.trim() ? highlightFormula(ta.value) : FORMULA_PLACEHOLDER; };
|
|
const syncScroll = () => { if (overlay && ta) { overlay.scrollLeft = ta.scrollLeft; overlay.scrollTop = ta.scrollTop; } };
|
|
|
|
// Highlight the table column whose [Col]/{Col...} reference the caret sits in.
|
|
const refNameAtCaret = (text: string, pos: number): string | null => {
|
|
let i = 0;
|
|
while (i < text.length) {
|
|
if (text[i] === "[") {
|
|
const end = text.indexOf("]", i + 1);
|
|
const close = end < 0 ? text.length : end;
|
|
if (pos >= i && pos <= close) return text.slice(i + 1, close).trim();
|
|
i = close + 1; continue;
|
|
}
|
|
if (text[i] === "{") {
|
|
let depth = 1, j = i + 1;
|
|
while (j < text.length && depth > 0) { if (text[j] === "{") depth++; else if (text[j] === "}") depth--; j++; }
|
|
const close = depth === 0 ? j - 1 : text.length;
|
|
if (pos >= i && pos <= close) return text.slice(i + 1, close).split(":")[0].trim();
|
|
i = close + 1; continue;
|
|
}
|
|
i++;
|
|
}
|
|
return null;
|
|
};
|
|
const updateCaretHighlight = () => {
|
|
if (!ta) { highlight(null); return; }
|
|
const name = refNameAtCaret(ta.value, ta.selectionStart ?? 0);
|
|
const lc = (name ?? "").toLowerCase();
|
|
const match = name ? operandOptions().find(o => o.label.toLowerCase() === lc) : null;
|
|
highlight(match ? [match.value] : null);
|
|
};
|
|
const onFormulaInput = (v: string) => { setFormula(v); emit(); renderOverlay(); syncScroll(); updateCaretHighlight(); };
|
|
// Insert text at the caret (replacing any selection), then restore focus.
|
|
const insertAt = (build: (cur: string, start: number, end: number) => { next: string; caret: number }) => {
|
|
if (!ta) return;
|
|
const cur = ta.value;
|
|
const start = ta.selectionStart ?? cur.length;
|
|
const end = ta.selectionEnd ?? cur.length;
|
|
const { next, caret } = build(cur, start, end);
|
|
ta.value = next; setFormula(next); emit(); renderOverlay();
|
|
requestAnimationFrame(() => { if (ta) { ta.focus(); ta.setSelectionRange(caret, caret); syncScroll(); updateCaretHighlight(); } });
|
|
};
|
|
const insertRef = (text: string) => insertAt((cur, start, end) => ({ next: cur.slice(0, start) + text + cur.slice(end), caret: start + text.length }));
|
|
// NAME(...) — any current selection becomes the first argument, else caret lands inside the parens.
|
|
const insertFunction = (name: string) => insertAt((cur, start, end) => {
|
|
const selected = cur.slice(start, end);
|
|
const insert = name + "(" + selected + ")";
|
|
return { next: cur.slice(0, start) + insert + cur.slice(end), caret: selected ? start + insert.length : start + name.length + 1 };
|
|
});
|
|
|
|
const setSpec = (s: FormulaSpec) => {
|
|
setFn(s.fn || "sum");
|
|
const ops = s.operands ?? [];
|
|
setSingleOperand(ops[0] ?? "");
|
|
setOperandA(ops[0] ?? "");
|
|
setOperandB(ops[1] ?? "");
|
|
setMultiOperands(ops);
|
|
setFormula(s.formula ?? "");
|
|
if (ta) { ta.value = s.formula ?? ""; renderOverlay(); }
|
|
emit();
|
|
};
|
|
if (props.apiRef) props.apiRef({ setSpec });
|
|
|
|
const setMode = (advanced: boolean) => { setFn(advanced ? "custom" : functionOptions()[0].value); highlight(null); emit(); };
|
|
const chooseFn = (v: string) => { setFn(v); emit(); };
|
|
|
|
// Function menu, filtered by the search box (matches name, signature or description).
|
|
const filteredFunctionGroups = () => {
|
|
const q = funcSearch().trim().toLowerCase();
|
|
if (!q) return FORMULA_FUNCTION_GROUPS;
|
|
return FORMULA_FUNCTION_GROUPS
|
|
.map(g => ({ label: g.label, fns: g.fns.filter(f => (f.name + " " + f.sig + " " + f.desc).toLowerCase().includes(q)) }))
|
|
.filter(g => g.fns.length > 0);
|
|
};
|
|
// Reset the search when the menu closes; focus it when it opens.
|
|
createEffect(() => {
|
|
if (funcMenuOpen()) requestAnimationFrame(() => funcSearchInput?.focus());
|
|
else setFuncSearch("");
|
|
});
|
|
|
|
// Column insert menu, filtered by its search box (matches the column name).
|
|
const filteredColumns = () => {
|
|
const q = colSearch().trim().toLowerCase();
|
|
const all = operandOptions;
|
|
return q ? all().filter(o => o.label.toLowerCase().includes(q)) : all();
|
|
};
|
|
createEffect(() => {
|
|
if (colMenuOpen()) requestAnimationFrame(() => colSearchInput?.focus());
|
|
else setColSearch("");
|
|
});
|
|
|
|
// Constant menu, filtered by its search box (matches name or description).
|
|
const filteredConstants = () => {
|
|
const q = constSearch().trim().toLowerCase();
|
|
return q ? FORMULA_CONSTANT_OPTIONS.filter(c => (c.name + " " + c.desc).toLowerCase().includes(q)) : FORMULA_CONSTANT_OPTIONS;
|
|
};
|
|
createEffect(() => {
|
|
if (constMenuOpen()) requestAnimationFrame(() => constSearchInput?.focus());
|
|
else setConstSearch("");
|
|
});
|
|
|
|
// Clear the table highlight and close the insert menus when the popover closes.
|
|
createEffect(() => { if (!ctx.isOpen()) { highlight(null); setFuncMenuOpen(false); setColMenuOpen(false); setConstMenuOpen(false); } });
|
|
|
|
const basicEditor = () => <div class="flex flex-col gap-2"
|
|
onMouseEnter={() => highlight(currentOperands())} onMouseLeave={() => highlight(null)}>
|
|
<FormSelect small={true} class="w-full" value={fn()}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => chooseFn(e.currentTarget.value)}>
|
|
<For each={functionOptions()}>
|
|
{(f: { value: string; label: string }) => <option value={f.value}>{f.label}</option>}
|
|
</For>
|
|
</FormSelect>
|
|
<Show when={mode() === "summary"}>
|
|
<FormCombobox small={true} class="w-full" searchable={true}
|
|
options={operandOptions()} value={singleOperand()}
|
|
onchange={(v: string) => { setSingleOperand(v); emit(); }} placeholder="Column…" />
|
|
</Show>
|
|
<Show when={mode() !== "summary" && isBinary()}>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<FormCombobox small={true} class="w-full" searchable={true}
|
|
options={operandOptions()} value={operandA()}
|
|
onchange={(v: string) => { setOperandA(v); emit(); }} placeholder={fn() === "divide" ? "Numerator…" : "Value…"} />
|
|
<FormCombobox small={true} class="w-full" searchable={true}
|
|
options={operandOptions()} value={operandB()}
|
|
onchange={(v: string) => { setOperandB(v); emit(); }} placeholder={fn() === "divide" ? "Denominator…" : "Minus…"} />
|
|
</div>
|
|
</Show>
|
|
<Show when={mode() !== "summary" && !isBinary()}>
|
|
<FormMultiSelect small={true} class="w-full" searchable={true}
|
|
options={operandOptions()} value={multiOperands()}
|
|
onchange={(v: string[]) => { setMultiOperands(v); emit(); }} placeholder="Select columns…" />
|
|
</Show>
|
|
</div>;
|
|
|
|
// -- Insert menus (Advanced mode), shown in a row above the formula input ----
|
|
const MENU_TRIGGER = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded-default border border-line-strong bg-surface-muted hover:bg-surface-raised text-ink cursor-pointer leading-none";
|
|
const MENU_SEARCH = "w-full text-xs p-1 border border-line-strong rounded-default focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
|
|
|
|
const columnMenu = () => <Show when={operandOptions.length}>
|
|
<Popover placement="bottom-start" standalone={true} open={colMenuOpen()} onOpenChange={setColMenuOpen}>
|
|
<PopoverTrigger class={MENU_TRIGGER}>Column <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
|
|
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
|
|
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
|
|
<input ref={(el: HTMLInputElement) => { colSearchInput = el; }} type="text" spellcheck="false"
|
|
placeholder="Search columns…" value={colSearch()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setColSearch(e.currentTarget.value)}
|
|
class={MENU_SEARCH} />
|
|
</div>
|
|
<div class="p-1.5">
|
|
<For each={filteredColumns()}>
|
|
{(o: FormSelectOption) => <div
|
|
class="flex items-center rounded-default hover:bg-surface-raised"
|
|
onMouseEnter={() => highlight([o.value])} onMouseLeave={() => highlight(null)}>
|
|
<button type="button"
|
|
class="flex-1 min-w-0 text-left truncate px-2 py-1 font-mono text-xs text-sky-600 dark:text-sky-400 bg-transparent border-0 cursor-pointer"
|
|
title={"Insert [" + o.label + "] — this row's cell"}
|
|
onclick={(_e: MouseEvent) => { insertRef("[" + o.label + "]"); setColMenuOpen(false); }}>[{o.label}]</button>
|
|
<button type="button"
|
|
class="px-2 py-1 text-xs font-mono text-violet-600 hover:text-violet-800 bg-transparent border-0 cursor-pointer"
|
|
title={"Insert {" + o.label + "} — whole column"}
|
|
onclick={(_e: MouseEvent) => { insertRef("{" + o.label + "}"); setColMenuOpen(false); }}>{"{ }"}</button>
|
|
</div>}
|
|
</For>
|
|
<Show when={filteredColumns().length === 0}>
|
|
<div class="px-2 py-3 text-xs text-ink-muted text-center">No columns match.</div>
|
|
</Show>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>;
|
|
|
|
const functionMenu = () => <Popover placement="bottom-start" standalone={true} open={funcMenuOpen()} onOpenChange={setFuncMenuOpen}>
|
|
<PopoverTrigger class={MENU_TRIGGER}>Function <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
|
|
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
|
|
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
|
|
<input ref={(el: HTMLInputElement) => { funcSearchInput = el; }} type="text" spellcheck="false"
|
|
placeholder="Search functions…" value={funcSearch()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setFuncSearch(e.currentTarget.value)}
|
|
class={MENU_SEARCH} />
|
|
</div>
|
|
<div class="p-1.5">
|
|
<For each={filteredFunctionGroups()}>
|
|
{(g: { label: string; fns: { name: string; sig: string; desc: string }[] }) => <div>
|
|
<div class="px-2 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">{g.label}</div>
|
|
<For each={g.fns}>
|
|
{(f: { name: string; sig: string; desc: string }) => <button type="button"
|
|
class="w-full text-left px-2 py-1 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex flex-col gap-0.5"
|
|
onclick={(_e: MouseEvent) => { insertFunction(f.name); setFuncMenuOpen(false); }}>
|
|
<span class="font-mono text-xs text-ink" innerHTML={highlightFormula(f.sig)}></span>
|
|
<span class="text-[11px] text-ink-muted">{f.desc}</span>
|
|
</button>}
|
|
</For>
|
|
</div>}
|
|
</For>
|
|
<Show when={filteredFunctionGroups().length === 0}>
|
|
<div class="px-2 py-3 text-xs text-ink-muted text-center">No functions match.</div>
|
|
</Show>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>;
|
|
|
|
const constantMenu = () => <Popover placement="bottom-start" standalone={true} open={constMenuOpen()} onOpenChange={setConstMenuOpen}>
|
|
<PopoverTrigger class={MENU_TRIGGER}>Constant <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
|
|
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
|
|
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
|
|
<input ref={(el: HTMLInputElement) => { constSearchInput = el; }} type="text" spellcheck="false"
|
|
placeholder="Search constants…" value={constSearch()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setConstSearch(e.currentTarget.value)}
|
|
class={MENU_SEARCH} />
|
|
</div>
|
|
<div class="p-1.5">
|
|
<For each={filteredConstants()}>
|
|
{(c: { name: string; desc: string }) => <button type="button"
|
|
class="w-full text-left px-2 py-1 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-center justify-between gap-2"
|
|
onclick={(_e: MouseEvent) => { insertRef(c.name); setConstMenuOpen(false); }}>
|
|
<span class="font-mono text-xs text-amber-600 dark:text-amber-400">{c.name}</span>
|
|
<span class="text-[11px] text-ink-muted">{c.desc}</span>
|
|
</button>}
|
|
</For>
|
|
<Show when={filteredConstants().length === 0}>
|
|
<div class="px-2 py-3 text-xs text-ink-muted text-center">No constants match.</div>
|
|
</Show>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>;
|
|
|
|
const advancedEditor = () => <div>
|
|
<div class="relative">
|
|
<div ref={(el: HTMLDivElement) => { overlay = el; }} class={FORMULA_OVERLAY_CLS} aria-hidden="true"></div>
|
|
<textarea ref={(el: HTMLTextAreaElement) => { ta = el; el.rows = 1; el.value = untrack(formula); renderOverlay(); }}
|
|
class={FORMULA_TEXTAREA_CLS} spellcheck="false" rows="1" wrap="off"
|
|
oninput={(e: InputEvent & { currentTarget: HTMLTextAreaElement }) => onFormulaInput(e.currentTarget.value)}
|
|
onKeyUp={updateCaretHighlight}
|
|
onclick={updateCaretHighlight}
|
|
onBlur={() => highlight(null)}
|
|
onScroll={syncScroll}></textarea>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-1.5 mt-2">
|
|
{columnMenu()}
|
|
{functionMenu()}
|
|
{constantMenu()}
|
|
</div>
|
|
</div>;
|
|
|
|
return <div>
|
|
<div class="flex items-center gap-1.5 mb-2">
|
|
<span class="text-ink text-sm font-medium leading-none">Formula</span>
|
|
<Show when={isAdvanced()}>
|
|
<HoverPopover placement="top" standalone={true}>
|
|
<HoverPopoverTrigger class="inline-flex items-center text-ink-faint hover:text-ink-soft cursor-help bg-transparent border-0 p-0 leading-none">
|
|
<Icon icon="circle-info" size={14} />
|
|
</HoverPopoverTrigger>
|
|
<HoverPopoverContent class="p-3 w-80 text-xs text-ink-soft leading-relaxed flex flex-col gap-2">
|
|
<div>
|
|
<div class="font-medium text-ink mb-1">References</div>
|
|
<div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">[Col]</code><span>this row's cell</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col}"}</code><span>the whole column</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col:n}"}</code><span>value in row n</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col:a:b}"}</code><span>rows a through b</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-2">
|
|
<div class="font-medium text-ink mb-1">Operators</div>
|
|
<div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">{"+ - * /"}</code><span>add, subtract, multiply, divide</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">^</code><span>power (a to the b)</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">%</code><span>remainder (modulo)</span>
|
|
<code class="bg-surface-raised px-1 rounded justify-self-start">{"= <> < > <= >="}</code><span>compare (yields 1 or 0)</span>
|
|
</div>
|
|
</div>
|
|
</HoverPopoverContent>
|
|
</HoverPopover>
|
|
</Show>
|
|
<SegmentedButtons class="ml-auto shrink-0" small={true}
|
|
options={[{ value: "basic", label: "Basic" }, { value: "advanced", label: "Advanced" }]}
|
|
value={isAdvanced() ? "advanced" : "basic"}
|
|
onchange={(v: string) => setMode(v === "advanced")} />
|
|
</div>
|
|
<Show when={isAdvanced()} fallback={basicEditor()}>
|
|
{advancedEditor()}
|
|
</Show>
|
|
</div>;
|
|
}
|
|
|
|
interface CalculatedColumnFormProps {
|
|
column: () => UserCalculatedColumn | null; // null = add mode
|
|
operandOptions: () => FormSelectOption[];
|
|
onSave: (col: UserCalculatedColumn) => void;
|
|
onRemove?: (id: string) => void;
|
|
// Highlight columns in the table while editing (operand keys, or null/[]).
|
|
onHighlight?: (keys: string[] | null) => void;
|
|
}
|
|
|
|
// Editor for a calculated column, rendered inside a Popover. Reads the floating
|
|
// context to prefill when the popover opens and to close itself on save/cancel.
|
|
// A column is defined by an Excel-style formula (see compileFormula); the insert
|
|
// chips reference other columns as [Col]/{Col}, including other calculated
|
|
// columns (but never this one). Saved columns always have fn "custom".
|
|
function CalculatedColumnForm(props: CalculatedColumnFormProps) {
|
|
const ctx = useFloatingContext();
|
|
const [name, setName] = createSignal("");
|
|
const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec);
|
|
const [dataType, setDataType] = createSignal("number" as CalculatedDataType);
|
|
const [decimals, setDecimals] = createSignal("");
|
|
const [position, setPosition] = createSignal(String(COL_POS_RIGHT));
|
|
const [errorMsg, setErrorMsg] = createSignal("");
|
|
let formulaApi: FormulaFieldApi | undefined;
|
|
|
|
// wrapProps may deliver these as the unwrapped value or as a function.
|
|
const column = () => typeof props.column === "function" ? (props.column as () => UserCalculatedColumn | null)() : (props.column as unknown as UserCalculatedColumn | null);
|
|
const allOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]);
|
|
// Exclude this column from its own reference choices.
|
|
const operandOptions = () => {
|
|
const ed = column();
|
|
const selfRef = ed ? "_calc_" + ed.id : null;
|
|
return allOptions().filter(o => o.value !== selfRef);
|
|
};
|
|
const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);
|
|
|
|
// Prefill the form each time the popover opens (FormulaField is seeded via its api).
|
|
createEffect(() => {
|
|
if (!ctx.isOpen()) return;
|
|
untrack(() => {
|
|
const ed = column();
|
|
setName(ed?.displayName ?? "");
|
|
setDataType(ed?.dataType ?? "number");
|
|
setDecimals(ed?.precision != null ? String(ed.precision) : "");
|
|
setPosition(String(ed?.displayPosition ?? COL_POS_RIGHT));
|
|
setErrorMsg("");
|
|
formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" });
|
|
});
|
|
});
|
|
|
|
// Live formula validation feedback (Advanced/custom only).
|
|
const formulaError = createMemo(() => {
|
|
const s = spec();
|
|
if (s.fn !== "custom" || !s.formula.trim()) return "";
|
|
try { compileFormula(s.formula); return ""; }
|
|
catch (e) { return e instanceof Error ? e.message : "Invalid formula"; }
|
|
});
|
|
|
|
const close = () => ctx.setIsOpen(false);
|
|
|
|
const handleSave = () => {
|
|
const trimmedName = name().trim();
|
|
if (!trimmedName) { setErrorMsg("Enter a column name."); return; }
|
|
const s = spec();
|
|
let operands: string[] = [];
|
|
let formulaText: string | undefined;
|
|
if (s.fn === "custom") {
|
|
formulaText = s.formula.trim();
|
|
if (!formulaText) { setErrorMsg("Enter a formula."); return; }
|
|
try { compileFormula(formulaText); }
|
|
catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; }
|
|
} else {
|
|
operands = s.operands.filter(Boolean);
|
|
if (isBinaryCalcFn(s.fn)) { if (operands.length < 2) { setErrorMsg("Pick both columns."); return; } }
|
|
else if (operands.length < 1) { setErrorMsg("Pick at least one column."); return; }
|
|
}
|
|
const ed = column();
|
|
const decTrimmed = decimals().trim();
|
|
const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10);
|
|
const col: UserCalculatedColumn = {
|
|
id: ed?.id ?? makeCalcColumnId(),
|
|
displayName: trimmedName,
|
|
fn: s.fn as CalculatedFunction | "custom",
|
|
operands,
|
|
formula: formulaText,
|
|
dataType: dataType(),
|
|
precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision,
|
|
displayPosition: parseInt(position(), 10) as ColumnPosition,
|
|
};
|
|
// Close before onSave: editing replaces this column's object, which
|
|
// disposes this popover's row, so closing afterward would target a
|
|
// disposed floating context.
|
|
close();
|
|
props.onSave(col);
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
const ed = column();
|
|
close();
|
|
if (ed && props.onRemove) props.onRemove(ed.id);
|
|
};
|
|
|
|
return <div class="flex flex-col gap-3 p-4 w-96">
|
|
<div class="text-sm font-semibold text-ink">{column() ? "Edit calculation" : "New calculation"}</div>
|
|
<div>
|
|
<FormLabel>Column Name</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="e.g. Total"
|
|
value={name()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setName(e.currentTarget.value)} />
|
|
</div>
|
|
<div>
|
|
<FormulaField
|
|
mode="column"
|
|
onchange={setSpec}
|
|
operandOptions={operandOptions}
|
|
onHighlight={highlight}
|
|
apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
|
|
<Show when={formulaError()}>
|
|
<p class="text-xs text-red-600 dark:text-red-400 mt-1">{formulaError()}</p>
|
|
</Show>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<FormLabel>Data type</FormLabel>
|
|
<FormSelect small={true} class="w-full"
|
|
value={dataType()}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
|
|
<For each={CALC_DATATYPE_OPTIONS}>
|
|
{(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
|
|
</For>
|
|
</FormSelect>
|
|
</div>
|
|
<div>
|
|
<FormLabel>Decimals</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
|
|
value={decimals()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
|
|
</div>
|
|
<div class="col-span-2">
|
|
<FormLabel>Alignment</FormLabel>
|
|
<FormSelect small={true} class="w-full"
|
|
value={position()}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPosition(e.currentTarget.value)}>
|
|
<For each={CALC_POSITION_OPTIONS}>
|
|
{(p: { value: string; label: string }) => <option value={p.value}>{p.label}</option>}
|
|
</For>
|
|
</FormSelect>
|
|
</div>
|
|
</div>
|
|
<Show when={errorMsg()}>
|
|
<p class="text-xs text-red-600 dark:text-red-400">{errorMsg()}</p>
|
|
</Show>
|
|
<div class="flex gap-2 items-center pt-1">
|
|
<Show when={!!column() && !!props.onRemove}>
|
|
<ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
|
|
</Show>
|
|
<div class="flex gap-2 items-center ml-auto">
|
|
<ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
|
|
<ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
|
|
</div>
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
interface SummaryRowFormProps {
|
|
row: () => UserSummaryRow | null; // null = add mode
|
|
operandOptions: () => FormSelectOption[]; // display-under columns + formula refs
|
|
onSave: (row: UserSummaryRow) => void;
|
|
onRemove?: (id: string) => void;
|
|
onHighlight?: (keys: string[] | null) => void;
|
|
}
|
|
|
|
// Editor for a footer summary line (Total, Subtotal, …): a label and a single-value
|
|
// calculation (FormulaField). The value is always shown right-aligned in the footer.
|
|
// Mirrors CalculatedColumnForm's lifecycle (prefill on open, close before onSave).
|
|
function SummaryRowForm(props: SummaryRowFormProps) {
|
|
const ctx = useFloatingContext();
|
|
const [label, setLabel] = createSignal("");
|
|
const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec);
|
|
const [dataType, setDataType] = createSignal("number" as CalculatedDataType);
|
|
const [decimals, setDecimals] = createSignal("");
|
|
const [errorMsg, setErrorMsg] = createSignal("");
|
|
let formulaApi: FormulaFieldApi | undefined;
|
|
|
|
const row = () => typeof props.row === "function" ? (props.row as () => UserSummaryRow | null)() : (props.row as unknown as UserSummaryRow | null);
|
|
const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]) || [];
|
|
const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys);
|
|
|
|
createEffect(() => {
|
|
if (!ctx.isOpen()) return;
|
|
untrack(() => {
|
|
const ed = row();
|
|
setLabel(ed?.label ?? "");
|
|
setDataType(ed?.dataType ?? "number");
|
|
setDecimals(ed?.precision != null ? String(ed.precision) : "");
|
|
setErrorMsg("");
|
|
formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" });
|
|
});
|
|
});
|
|
|
|
const formulaError = createMemo(() => {
|
|
const s = spec();
|
|
if (s.fn !== "custom" || !s.formula.trim()) return "";
|
|
try { compileFormula(s.formula); return ""; }
|
|
catch (e) { return e instanceof Error ? e.message : "Invalid formula"; }
|
|
});
|
|
|
|
const close = () => ctx.setIsOpen(false);
|
|
const handleSave = () => {
|
|
const trimmedLabel = label().trim();
|
|
if (!trimmedLabel) { setErrorMsg("Enter a label."); return; }
|
|
const s = spec();
|
|
let operands: string[] = [];
|
|
let formulaText: string | undefined;
|
|
if (s.fn === "custom") {
|
|
formulaText = s.formula.trim();
|
|
if (!formulaText) { setErrorMsg("Enter a formula."); return; }
|
|
try { compileFormula(formulaText); } catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; }
|
|
} else {
|
|
operands = s.operands.filter(Boolean);
|
|
if (operands.length < 1) { setErrorMsg("Pick a column."); return; }
|
|
}
|
|
const ed = row();
|
|
const decTrimmed = decimals().trim();
|
|
const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10);
|
|
const out: UserSummaryRow = {
|
|
id: ed?.id ?? makeCalcColumnId(),
|
|
label: trimmedLabel,
|
|
fn: s.fn as CalculatedFunction | "custom",
|
|
operands,
|
|
formula: formulaText,
|
|
dataType: dataType(),
|
|
precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision,
|
|
};
|
|
close();
|
|
props.onSave(out);
|
|
};
|
|
const handleRemove = () => { const ed = row(); close(); if (ed && props.onRemove) props.onRemove(ed.id); };
|
|
|
|
return <div class="flex flex-col gap-3 p-4 w-96">
|
|
<div class="text-sm font-semibold text-ink">{row() ? "Edit summary" : "New summary"}</div>
|
|
<div>
|
|
<FormLabel>Label</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="e.g. Total"
|
|
value={label()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setLabel(e.currentTarget.value)} />
|
|
</div>
|
|
<div>
|
|
<FormulaField
|
|
mode="summary"
|
|
onchange={setSpec}
|
|
operandOptions={operandOptions}
|
|
onHighlight={highlight}
|
|
apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
|
|
<Show when={formulaError()}>
|
|
<p class="text-xs text-red-600 dark:text-red-400 mt-1">{formulaError()}</p>
|
|
</Show>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<FormLabel>Data type</FormLabel>
|
|
<FormSelect small={true} class="w-full"
|
|
value={dataType()}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
|
|
<For each={CALC_DATATYPE_OPTIONS}>
|
|
{(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
|
|
</For>
|
|
</FormSelect>
|
|
</div>
|
|
<div>
|
|
<FormLabel>Decimals</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
|
|
value={decimals()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
|
|
</div>
|
|
</div>
|
|
<Show when={errorMsg()}>
|
|
<p class="text-xs text-red-600 dark:text-red-400">{errorMsg()}</p>
|
|
</Show>
|
|
<div class="flex gap-2 items-center pt-1">
|
|
<Show when={!!row() && !!props.onRemove}>
|
|
<ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
|
|
</Show>
|
|
<div class="flex gap-2 items-center ml-auto">
|
|
<ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
|
|
<ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
|
|
</div>
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
interface AddCalcMenuProps {
|
|
allowColumn: boolean;
|
|
allowSummary: boolean;
|
|
operandOptions: () => FormSelectOption[];
|
|
onSaveColumn: (col: UserCalculatedColumn) => void;
|
|
onSaveSummary: (row: UserSummaryRow) => void;
|
|
onHighlight?: (keys: string[] | null) => void;
|
|
}
|
|
|
|
// Content of the single toolbar "Add Calculation" popover: a chooser listing the
|
|
// enabled options (calculated column and/or summary row), which then swaps to the
|
|
// chosen editor in the same popover (so both editors share this floating context).
|
|
// The chooser is always shown and resets when the popover closes.
|
|
function AddCalcMenu(props: AddCalcMenuProps) {
|
|
const ctx = useFloatingContext();
|
|
const allowColumn = () => typeof props.allowColumn === "function" ? (props.allowColumn as () => boolean)() : props.allowColumn;
|
|
const allowSummary = () => typeof props.allowSummary === "function" ? (props.allowSummary as () => boolean)() : props.allowSummary;
|
|
// Forward as an accessor (a function) so the binding stays reactive — passing
|
|
// props.operandOptions directly would snapshot the array (losing new columns).
|
|
const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]);
|
|
const [view, setView] = createSignal("menu");
|
|
createEffect(() => { if (!ctx.isOpen()) setView("menu"); });
|
|
|
|
const ITEM = "w-full text-left px-2 py-2 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5";
|
|
return <>
|
|
<Show when={view() === "menu"}>
|
|
<div class="flex flex-col gap-0.5 p-2 w-96">
|
|
<Show when={allowColumn()}>
|
|
<button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("column")}>
|
|
<Icon icon="table-columns" size={16} class="mt-0.5 text-ink-muted" />
|
|
<span class="flex flex-col">
|
|
<span class="text-sm font-medium text-ink">Calculated column</span>
|
|
<span class="text-xs text-ink-muted">A new column computed for each row</span>
|
|
</span>
|
|
</button>
|
|
</Show>
|
|
<Show when={allowSummary()}>
|
|
<button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("summary")}>
|
|
<Icon icon="list-ol" size={16} class="mt-0.5 text-ink-muted" />
|
|
<span class="flex flex-col">
|
|
<span class="text-sm font-medium text-ink">Summary row</span>
|
|
<span class="text-xs text-ink-muted">A total or aggregate shown in the footer</span>
|
|
</span>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
<Show when={view() === "column"}>
|
|
<CalculatedColumnForm
|
|
column={() => null}
|
|
operandOptions={operandOptions}
|
|
onSave={props.onSaveColumn}
|
|
onHighlight={props.onHighlight} />
|
|
</Show>
|
|
<Show when={view() === "summary"}>
|
|
<SummaryRowForm
|
|
row={() => null}
|
|
operandOptions={operandOptions}
|
|
onSave={props.onSaveSummary}
|
|
onHighlight={props.onHighlight} />
|
|
</Show>
|
|
</>;
|
|
}
|
|
|
|
/**
|
|
* Feature-rich data table with sorting, pagination, search, drag-to-reorder columns,
|
|
* column visibility toggle, CSV export, accordion rows, and user-defined
|
|
* calculated columns.
|
|
* Supports both local data and remote data fetching with optional server-side filtering.
|
|
*/
|
|
export function AutoTable(props: AutoTableProps) {
|
|
const isRemote = () => !!props.url;
|
|
const isLocalFiltering = () => !isRemote() || !props.remoteFiltering;
|
|
|
|
const opts = createMemo(() => {
|
|
const merged = {
|
|
size: AUTOTABLE_SIZE_DEFAULT,
|
|
shadow: false,
|
|
hover: false,
|
|
alternate: false,
|
|
headerBorderY: false,
|
|
surroundingBorder: false,
|
|
borderX: false,
|
|
borderY: false,
|
|
color: AUTOTABLE_HEADER_COLOR_DEFAULT,
|
|
tableLayoutAuto: false,
|
|
hidePagination: false,
|
|
paginationShowAll: false,
|
|
draggableColumns: false,
|
|
columnOrderStorageKey: undefined,
|
|
toggleColumns: false,
|
|
columnVisibilityStorageKey: undefined,
|
|
resizableColumns: false,
|
|
columnWidthStorageKey: undefined,
|
|
resetButton: false,
|
|
exportCSV: false,
|
|
exportFilename: "export",
|
|
pdfHeader: undefined,
|
|
accordion: false,
|
|
accordionSingle: false,
|
|
searchAside: false,
|
|
calculatedColumns: false,
|
|
calculatedColumnsStorageKey: undefined,
|
|
summaryRows: false,
|
|
summaryRowsStorageKey: undefined,
|
|
userCustomizeExport: false,
|
|
...props.options,
|
|
};
|
|
if (merged.inlineToolbar === undefined && props.searchFields && !merged.searchAside) {
|
|
merged.inlineToolbar = !!(
|
|
merged.exportCSV
|
|
|| merged.toggleColumns
|
|
|| props.exportMenuItems
|
|
|| props.toolbarActions
|
|
);
|
|
}
|
|
return merged;
|
|
});
|
|
|
|
const accordionKey = () => props.accordionKey ?? "accordion";
|
|
|
|
const [expandedRows, setExpandedRows] = createSignal(new Set() as Set<any>);
|
|
|
|
const toggleAccordion = (rowKey: any) => {
|
|
setExpandedRows(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(rowKey)) {
|
|
next.delete(rowKey);
|
|
} else {
|
|
if (opts().accordionSingle) {
|
|
next.clear();
|
|
}
|
|
next.add(rowKey);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const [remoteData, setRemoteData] = createSignal([] as any[]);
|
|
const [initialLoading, setInitialLoading] = createSignal(isRemote());
|
|
const [showLoading, setShowLoading] = createSignal(false);
|
|
const [error, setError] = createSignal(null as string | null);
|
|
const [isExporting, setIsExporting] = createSignal(false);
|
|
|
|
// User-editable export settings (used when opts().userCustomizeExport). Seeded
|
|
// from the developer-provided defaults; persist for the table's lifetime.
|
|
const [exportFilenameInput, setExportFilenameInput] = createSignal(opts().exportFilename);
|
|
const [pdfTitle, setPdfTitle] = createSignal(opts().pdfHeader?.title ?? "");
|
|
const [pdfSubtitle, setPdfSubtitle] = createSignal(opts().pdfHeader?.subtitle ?? "");
|
|
const [pdfOrientation, setPdfOrientation] = createSignal(String(opts().pdfHeader?.orientation ?? PDF_ORIENTATION_LANDSCAPE));
|
|
const [pdfShowDate, setPdfShowDate] = createSignal(opts().pdfHeader?.showDate ?? true);
|
|
|
|
const effectiveExportFilename = () => opts().userCustomizeExport ? (exportFilenameInput().trim() || "export") : opts().exportFilename;
|
|
const effectivePdfHeader = (): AutoTablePDFHeader | undefined => {
|
|
if (!opts().userCustomizeExport) return opts().pdfHeader;
|
|
// showLogo and logoUrl stay developer-controlled (not user-editable).
|
|
return {
|
|
...opts().pdfHeader,
|
|
title: pdfTitle(),
|
|
subtitle: pdfSubtitle(),
|
|
orientation: parseInt(pdfOrientation(), 10) as PDFOrientation,
|
|
showDate: pdfShowDate(),
|
|
};
|
|
};
|
|
|
|
const [draggedColumn, setDraggedColumn] = createSignal(null as number | null);
|
|
const [dragOverColumn, setDragOverColumn] = createSignal(null as number | null);
|
|
const [draggedSummary, setDraggedSummary] = createSignal(null as number | null);
|
|
const [dragOverSummary, setDragOverSummary] = createSignal(null as number | null);
|
|
|
|
// -- Resizable columns ----------------------------------------------------
|
|
// Widths (px) keyed by the same stable keys as the order system
|
|
// ("d<index>" for data columns, "_calc_<id>" for calc columns).
|
|
const loadColumnWidths = (): Record<string, number> => {
|
|
const key = opts().columnWidthStorageKey;
|
|
if (key) {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed && typeof parsed === "object") return parsed;
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
return {};
|
|
};
|
|
const [columnWidths, setColumnWidths] = createSignal(loadColumnWidths());
|
|
const [resizingColumn, setResizingColumn] = createSignal(null as string | null);
|
|
|
|
createEffect(() => {
|
|
const key = opts().columnWidthStorageKey;
|
|
if (key && opts().resizableColumns) {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(columnWidths()));
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
});
|
|
|
|
const MIN_COLUMN_WIDTH = 56;
|
|
// Dragging a column boundary trades width between that column and its right
|
|
// neighbor, keeping the total constant. This keeps the table within its
|
|
// container (no runaway horizontal overflow) while still letting the user
|
|
// set the proportions — the intuitive spreadsheet "move the boundary" feel.
|
|
const startColumnResize = (e: MouseEvent, key: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const th = (e.currentTarget as HTMLElement).closest("th") as HTMLElement | null;
|
|
const row = th?.parentElement;
|
|
const startX = e.clientX;
|
|
|
|
// Pin EVERY column to its current rendered width so the layout is fully
|
|
// determined; without this the unsized columns reflow when one changes.
|
|
const snapshot: Record<string, number> = { ...columnWidths() };
|
|
const ths: HTMLElement[] = row
|
|
? Array.from(row.querySelectorAll("th[data-colkey]")) as HTMLElement[]
|
|
: [];
|
|
ths.forEach((el) => {
|
|
const k = el.getAttribute("data-colkey");
|
|
if (k) snapshot[k] = el.getBoundingClientRect().width;
|
|
});
|
|
setColumnWidths(snapshot);
|
|
setResizingColumn(key);
|
|
|
|
const startWidth = snapshot[key] ?? (th ? th.getBoundingClientRect().width : 120);
|
|
// The right neighbor absorbs the change (the last column has no handle, so
|
|
// a neighbor always exists). Falls back to a plain grow if none is found.
|
|
const idx = ths.findIndex((el) => el.getAttribute("data-colkey") === key);
|
|
const nextKey = idx >= 0 && idx + 1 < ths.length ? ths[idx + 1].getAttribute("data-colkey") : null;
|
|
const startNextWidth = nextKey ? (snapshot[nextKey] ?? 0) : 0;
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const rawDelta = ev.clientX - startX;
|
|
if (nextKey) {
|
|
// Clamp so neither the dragged column nor its neighbor drops below
|
|
// the minimum; the pair's combined width never changes.
|
|
const delta = Math.max(
|
|
MIN_COLUMN_WIDTH - startWidth,
|
|
Math.min(startNextWidth - MIN_COLUMN_WIDTH, rawDelta),
|
|
);
|
|
const newWidth = Math.round(startWidth + delta);
|
|
const newNext = startWidth + startNextWidth - newWidth;
|
|
setColumnWidths(prev => ({ ...prev, [key]: newWidth, [nextKey]: newNext }));
|
|
} else {
|
|
const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(startWidth + rawDelta));
|
|
setColumnWidths(prev => ({ ...prev, [key]: newWidth }));
|
|
}
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
setResizingColumn(null);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
// Inline width style for a header cell, or undefined when unset.
|
|
const columnWidthStyle = (key: string) => {
|
|
const w = columnWidths()[key];
|
|
return w ? { width: w + "px" } : undefined;
|
|
};
|
|
|
|
|
|
const getInitialVisibleColumns = () => {
|
|
if (opts().columnVisibilityStorageKey) {
|
|
try {
|
|
const stored = localStorage.getItem(opts().columnVisibilityStorageKey);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
return props.columns
|
|
.map((col, i) => ({ col, i }))
|
|
.filter(({ col }) => !col.hiddenByDefault)
|
|
.map(({ i }) => i.toString());
|
|
};
|
|
const [visibleColumns, setVisibleColumns] = createSignal(getInitialVisibleColumns());
|
|
|
|
createEffect(on(
|
|
() => props.columns.length,
|
|
() => {
|
|
const allIndices = props.columns.map((_, i) => i.toString());
|
|
const validVisible = visibleColumns().filter(v => allIndices.includes(v));
|
|
const newColumns = allIndices.filter(i => !visibleColumns().includes(i) && !props.columns[parseInt(i)]?.hiddenByDefault);
|
|
if (newColumns.length > 0 || validVisible.length !== visibleColumns().length) {
|
|
setVisibleColumns([...validVisible, ...newColumns]);
|
|
}
|
|
}
|
|
));
|
|
|
|
createEffect(() => {
|
|
if (opts().columnVisibilityStorageKey && opts().toggleColumns) {
|
|
try {
|
|
localStorage.setItem(opts().columnVisibilityStorageKey, JSON.stringify(visibleColumns()));
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
});
|
|
|
|
// -- User-created calculated columns -------------------------------------
|
|
const loadUserCalcColumns = (): UserCalculatedColumn[] => {
|
|
const key = opts().calculatedColumnsStorageKey;
|
|
if (key) {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed)) return parsed;
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
const [userCalcColumns, setUserCalcColumns] = createSignal(loadUserCalcColumns());
|
|
|
|
createEffect(() => {
|
|
const key = opts().calculatedColumnsStorageKey;
|
|
if (key && opts().calculatedColumns) {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(userCalcColumns()));
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
});
|
|
|
|
const CALC_REF_PREFIX = "_calc_";
|
|
|
|
// Lookups rebuilt whenever the set of calc columns changes.
|
|
const calcById = createMemo(() => {
|
|
const m = new Map<string, UserCalculatedColumn>();
|
|
for (const c of userCalcColumns()) m.set(c.id, c);
|
|
return m;
|
|
});
|
|
// Maps a referenceable display name (lowercased) to an operand key, so custom
|
|
// formulas can use [Display Name]. Data columns resolve to their
|
|
// sortIdentifier; calc columns to "_calc_<id>".
|
|
const calcNameToRef = createMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const col of props.columns) if (col.sortIdentifier) m.set(col.displayName.trim().toLowerCase(), col.sortIdentifier);
|
|
for (const c of userCalcColumns()) m.set(c.displayName.trim().toLowerCase(), CALC_REF_PREFIX + c.id);
|
|
return m;
|
|
});
|
|
// Compile custom formulas once per change (not per row); invalid -> null.
|
|
const compiledFormulas = createMemo(() => {
|
|
const m = new Map<string, FormulaNode | null>();
|
|
for (const c of userCalcColumns()) {
|
|
if (c.fn === "custom") {
|
|
try { m.set(c.id, compileFormula(c.formula ?? "")); }
|
|
catch { m.set(c.id, null); }
|
|
}
|
|
}
|
|
return m;
|
|
});
|
|
|
|
// Build the per-row resolvers a formula needs. `visiting` holds the chain of
|
|
// calc column ids being evaluated so cycles resolve to NaN instead of
|
|
// recursing forever. [refs] read the current row, {refs} the whole filtered
|
|
// column (both may point at other calc columns).
|
|
const makeCalcResolvers = (item: any, visiting: Set<string>) => {
|
|
const resolveRef = (ref: string): number => {
|
|
if (ref.startsWith(CALC_REF_PREFIX)) {
|
|
const id = ref.slice(CALC_REF_PREFIX.length);
|
|
if (visiting.has(id)) return NaN;
|
|
const target = calcById().get(id);
|
|
if (!target) return NaN;
|
|
const next = new Set(visiting); next.add(id);
|
|
return computeUserColumn(target, item, next);
|
|
}
|
|
return toCalcNumber(item?.[ref]);
|
|
};
|
|
const resolveColumn = (ref: string): number[] => {
|
|
const rows = aggregateRows();
|
|
if (ref.startsWith(CALC_REF_PREFIX)) {
|
|
const id = ref.slice(CALC_REF_PREFIX.length);
|
|
if (visiting.has(id)) return [];
|
|
const target = calcById().get(id);
|
|
if (!target) return [];
|
|
const next = new Set(visiting); next.add(id);
|
|
return rows.map(r => computeUserColumn(target, r, next));
|
|
}
|
|
return rows.map(r => toCalcNumber(r?.[ref]));
|
|
};
|
|
const rowIndex = () => { const i = aggregateRows().indexOf(item); return i < 0 ? NaN : i + 1; };
|
|
return { resolveRef, resolveColumn, rowIndex };
|
|
};
|
|
|
|
// Evaluate a calc spec (a saved column or an unsaved draft) for one row.
|
|
const evalCalcSpec = (spec: { fn: UserCalculatedColumn["fn"]; operands: string[]; formula?: string }, compiled: FormulaNode | null, item: any, visiting: Set<string>): number => {
|
|
const { resolveRef, resolveColumn, rowIndex } = makeCalcResolvers(item, visiting);
|
|
if (spec.fn === "custom") {
|
|
if (!compiled) return NaN;
|
|
const refs = calcNameToRef();
|
|
const lookup = (name: string) => refs.get(name.trim().toLowerCase());
|
|
return toScalar(compiled({
|
|
cell: (name) => { const ref = lookup(name); return ref === undefined ? NaN : resolveRef(ref); },
|
|
column: (name) => { const ref = lookup(name); return ref === undefined ? [] : resolveColumn(ref); },
|
|
row: rowIndex,
|
|
}));
|
|
}
|
|
return applyCalcFunction(spec.fn, spec.operands.map(resolveRef));
|
|
};
|
|
|
|
const computeUserColumn = (uc: UserCalculatedColumn, item: any, visiting: Set<string>): number =>
|
|
evalCalcSpec(uc, uc.fn === "custom" ? (compiledFormulas().get(uc.id) ?? null) : null, item, visiting);
|
|
|
|
// Each user column becomes a regular AutoTableColumn carrying a `calculated`
|
|
// spec, so the existing render/sort/export paths handle it. The compute
|
|
// closure resolves calc-in-calc references and custom formulas; the synthetic
|
|
// sortIdentifier ("_calc_<id>") lets processDataLocally find and sort it.
|
|
const userCalcToColumn = (uc: UserCalculatedColumn): AutoTableColumn => ({
|
|
displayName: uc.displayName,
|
|
displayPosition: uc.displayPosition ?? COL_POS_RIGHT,
|
|
sortable: isLocalFiltering(),
|
|
sortIdentifier: CALC_REF_PREFIX + uc.id,
|
|
csv: true,
|
|
calculated: {
|
|
fn: uc.fn,
|
|
operands: [],
|
|
dataType: uc.dataType,
|
|
precision: uc.precision,
|
|
compute: (item: any) => computeUserColumn(uc, item, new Set([uc.id])),
|
|
},
|
|
});
|
|
const calcColumns = createMemo(() => userCalcColumns().map(userCalcToColumn));
|
|
// props.columns plus user calc columns — used for local sorting and export
|
|
// so calculated columns participate in both.
|
|
const effectiveColumns = createMemo(() => [...props.columns, ...calcColumns()]);
|
|
|
|
// Columns the user can reference as operands: data columns with a field key,
|
|
// plus other calculated columns (referenced as "_calc_<id>").
|
|
const calcOperandOptions = createMemo(() => {
|
|
const dataOpts = props.columns
|
|
.filter(c => !!c.sortIdentifier)
|
|
.map(c => ({ value: c.sortIdentifier!, label: c.displayName } as FormSelectOption));
|
|
const calcOpts = userCalcColumns()
|
|
.map(c => ({ value: CALC_REF_PREFIX + c.id, label: c.displayName } as FormSelectOption));
|
|
return [...dataOpts, ...calcOpts];
|
|
});
|
|
|
|
const removeCalcColumn = (id: string) => setUserCalcColumns(prev => prev.filter(c => c.id !== id));
|
|
const saveCalcColumn = (col: UserCalculatedColumn) => {
|
|
setUserCalcColumns(prev => {
|
|
const idx = prev.findIndex(c => c.id === col.id);
|
|
if (idx >= 0) {
|
|
const next = [...prev];
|
|
next[idx] = col;
|
|
return next;
|
|
}
|
|
return [...prev, col];
|
|
});
|
|
};
|
|
|
|
// -- User-created footer summary rows ------------------------------------
|
|
const loadUserSummaryRows = (): UserSummaryRow[] => {
|
|
const key = opts().summaryRowsStorageKey;
|
|
if (key) {
|
|
try {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed; }
|
|
} catch { /* ignore */ }
|
|
}
|
|
return [];
|
|
};
|
|
const [userSummaryRows, setUserSummaryRows] = createSignal(loadUserSummaryRows());
|
|
createEffect(() => {
|
|
const key = opts().summaryRowsStorageKey;
|
|
if (key && opts().summaryRows) {
|
|
try { localStorage.setItem(key, JSON.stringify(userSummaryRows())); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
const saveSummaryRow = (row: UserSummaryRow) => {
|
|
setUserSummaryRows((prev: UserSummaryRow[]) => {
|
|
const idx = prev.findIndex(r => r.id === row.id);
|
|
if (idx >= 0) { const next = [...prev]; next[idx] = row; return next; }
|
|
return [...prev, row];
|
|
});
|
|
};
|
|
const removeSummaryRow = (id: string) => setUserSummaryRows((prev: UserSummaryRow[]) => prev.filter(r => r.id !== id));
|
|
|
|
// Evaluate a summary formula once (no current row) over the filtered rows, then
|
|
// format it. Reuses the column engine with item=null: [cell]/ROW() resolve to
|
|
// NaN, {Col} aggregates resolve against aggregateRows().
|
|
const computeSummaryValue = (s: UserSummaryRow): string => {
|
|
const fn = s.fn ?? "custom";
|
|
let n: number;
|
|
if (fn === "custom") {
|
|
let compiled: FormulaNode | null = null;
|
|
try { compiled = compileFormula(s.formula ?? ""); } catch { return "—"; }
|
|
n = evalCalcSpec({ fn: "custom", operands: [], formula: s.formula }, compiled, null, new Set());
|
|
} else {
|
|
// Basic: aggregate the chosen column down the (filtered) rows.
|
|
const key = s.operands && s.operands[0];
|
|
if (!key) return "—";
|
|
const { resolveColumn } = makeCalcResolvers(null, new Set());
|
|
n = applyCalcFunction(fn as CalculatedFunction, resolveColumn(key));
|
|
}
|
|
return formatCalcResult(n, s.dataType, s.precision, undefined, undefined, "—");
|
|
};
|
|
// Computed summary lines for PDF export (label + formatted value).
|
|
const pdfSummaries = () => opts().summaryRows
|
|
? userSummaryRows().map((s: UserSummaryRow) => ({ label: s.label, value: computeSummaryValue(s) }))
|
|
: [];
|
|
|
|
|
|
// Columns the calc editor highlights on hover (operand keys: a data column's
|
|
// sortIdentifier or "_calc_<id>"). A header is lit when its sortIdentifier is
|
|
// in this set.
|
|
const [highlightedCols, setHighlightedCols] = createSignal([] as string[]);
|
|
const isColHighlighted = (col: AutoTableColumn) => !!col.sortIdentifier && highlightedCols().includes(col.sortIdentifier);
|
|
|
|
// -- Column ordering (drag-to-reorder) -----------------------------------
|
|
// Columns are ordered by stable string keys so data and calculated columns
|
|
// can interleave: data columns are "d<index>", calc columns use their
|
|
// sortIdentifier ("_calc_<id>"). Calc columns join the orderable list only
|
|
// when a cellRenderer is present (dragging needs per-cell control); with a
|
|
// rowRenderer they stay appended after the data columns and aren't draggable.
|
|
const DATA_COLUMN_KEY = (i: number) => "d" + i;
|
|
const calcMerged = () => !!props.cellRenderer;
|
|
const appendedCalcColumns = () => calcMerged() ? [] : calcColumns();
|
|
|
|
const allColumnKeys = createMemo(() => {
|
|
const dataKeys = props.columns.map((_, i) => DATA_COLUMN_KEY(i));
|
|
if (!calcMerged()) return dataKeys;
|
|
return [...dataKeys, ...userCalcColumns().map(uc => CALC_REF_PREFIX + uc.id)];
|
|
});
|
|
|
|
const columnByKey = createMemo(() => {
|
|
const m = new Map<string, { column: AutoTableColumn; originalIndex: number }>();
|
|
props.columns.forEach((col, i) => m.set(DATA_COLUMN_KEY(i), { column: col, originalIndex: i }));
|
|
if (calcMerged()) calcColumns().forEach((col) => m.set(col.sortIdentifier!, { column: col, originalIndex: -1 }));
|
|
return m;
|
|
});
|
|
|
|
const getInitialColumnOrder = (): string[] => {
|
|
if (opts().columnOrderStorageKey) {
|
|
try {
|
|
const stored = localStorage.getItem(opts().columnOrderStorageKey!);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed) && parsed.every((x: any) => typeof x === "string")) {
|
|
return parsed;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore localStorage errors (and the pre-key numeric format)
|
|
}
|
|
}
|
|
return allColumnKeys();
|
|
};
|
|
const [columnOrder, setColumnOrder] = createSignal(getInitialColumnOrder());
|
|
|
|
// Reconcile the saved order with the current column set: keep known keys in
|
|
// their order, append newly added columns, drop removed ones. Handles data
|
|
// column changes and calc columns being added/removed/reordered.
|
|
createEffect(() => {
|
|
const keys = allColumnKeys();
|
|
const keySet = new Set(keys);
|
|
const current = columnOrder();
|
|
const valid = current.filter(k => keySet.has(k));
|
|
const missing = keys.filter(k => !current.includes(k));
|
|
if (missing.length > 0 || valid.length !== current.length) {
|
|
setColumnOrder([...valid, ...missing]);
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (opts().columnOrderStorageKey && opts().draggableColumns) {
|
|
try {
|
|
localStorage.setItem(opts().columnOrderStorageKey, JSON.stringify(columnOrder()));
|
|
} catch {
|
|
// Ignore localStorage errors
|
|
}
|
|
}
|
|
});
|
|
|
|
let loadingTimerRef: ReturnType<typeof setTimeout> | null = null;
|
|
let debounceTimerRef: ReturnType<typeof setTimeout> | null = null;
|
|
let fetchIdRef = 0;
|
|
|
|
const [filter, setFilter] = createSignal({
|
|
Pagination: {
|
|
Disabled: false,
|
|
CurrentPage: 1,
|
|
NextPage: 1,
|
|
PreviousPage: 1,
|
|
TotalPages: 1,
|
|
TotalItems: 0,
|
|
MaxItemsPerPage: opts().hidePagination ? -1 : 25,
|
|
ItemsThisPage: 0,
|
|
ViewRangeLower: 0,
|
|
ViewRangeUpper: 0,
|
|
},
|
|
OrderBy: {
|
|
Identifier: "",
|
|
Descending: false,
|
|
},
|
|
Search: [] as AutoTableSearchEntry[],
|
|
...props.initialFilter,
|
|
} as AutoTableFilter);
|
|
|
|
const [searchInputs, setSearchInputs] = createSignal(props.initialFilter?.Search || []);
|
|
const [searchGeneration, setSearchGeneration] = createSignal(0);
|
|
const bumpSearchGeneration = () => setSearchGeneration((g) => g + 1);
|
|
|
|
const sourceData = createMemo(() => isRemote() ? remoteData() : (props.data || []));
|
|
|
|
// Rows that cross-row formula refs ({Column}) aggregate over: the filtered
|
|
// set (search applied, sort/pagination ignored). Independent of localProcessed
|
|
// so a calc column that aggregates can still be sorted without a cycle. For
|
|
// server-side filtering only the loaded page is available.
|
|
const aggregateRows = createMemo(() => isLocalFiltering()
|
|
? applySearchFilters(sourceData(), filter().Search)
|
|
: remoteData());
|
|
|
|
const localProcessed = createMemo(() => {
|
|
if (!isLocalFiltering()) return null;
|
|
return processDataLocally(sourceData(), filter(), effectiveColumns());
|
|
});
|
|
|
|
const displayData = createMemo(() => isLocalFiltering() ? (localProcessed()?.data || []) : remoteData());
|
|
const allFilteredData = createMemo(() => isLocalFiltering() ? (localProcessed()?.allFilteredData || []) : remoteData());
|
|
const displayPagination = createMemo(() => isLocalFiltering()
|
|
? (localProcessed()?.pagination || filter().Pagination)
|
|
: filter().Pagination);
|
|
|
|
createEffect(() => {
|
|
displayPagination().CurrentPage;
|
|
filter().OrderBy.Identifier;
|
|
filter().OrderBy.Descending;
|
|
filter().Search;
|
|
setExpandedRows(new Set());
|
|
});
|
|
|
|
// When a highlightMatch predicate is supplied and the matching row lives
|
|
// on a different page than the one currently shown, jump to that page.
|
|
//
|
|
// The effect tracks only the predicate identity (caller is expected to
|
|
// memoize it - a new function ref means "new highlight target") and
|
|
// props.data (so we re-evaluate once the data finishes loading). All
|
|
// other AutoTable state - filter, allFilteredData, pagination - is read
|
|
// via untrack so user-driven page changes don't re-fire the jump and
|
|
// pin them to the highlighted page.
|
|
createEffect(() => {
|
|
const match = props.highlightMatch;
|
|
if (!match) return;
|
|
// Touch props.data so the effect re-runs when the dataset loads.
|
|
const data = props.data;
|
|
if (!data) return;
|
|
untrack(() => {
|
|
const all = allFilteredData();
|
|
if (!all.length) return;
|
|
const idx = all.findIndex((it: any) => match(it));
|
|
if (idx < 0) return;
|
|
const f = filter();
|
|
const pageSize = f.Pagination.MaxItemsPerPage;
|
|
if (!pageSize || pageSize < 0) return;
|
|
const targetPage = Math.floor(idx / pageSize) + 1;
|
|
if (targetPage !== f.Pagination.CurrentPage) {
|
|
setFilter((prev: AutoTableFilter) => ({
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, CurrentPage: targetPage },
|
|
}));
|
|
}
|
|
});
|
|
});
|
|
|
|
const fetchData = async (filterToUse: AutoTableFilter, isInitial = false) => {
|
|
if (!props.url) return;
|
|
|
|
const currentFetchId = ++fetchIdRef;
|
|
setError(null);
|
|
|
|
if (loadingTimerRef) {
|
|
clearTimeout(loadingTimerRef);
|
|
loadingTimerRef = null;
|
|
}
|
|
|
|
if (!isInitial) {
|
|
loadingTimerRef = setTimeout(() => {
|
|
setShowLoading(true);
|
|
}, 500);
|
|
}
|
|
|
|
const startTime = isInitial ? Date.now() : 0;
|
|
const minLoadingDuration = 300;
|
|
|
|
try {
|
|
const fetchUrl = !props.remoteFiltering ? props.url : props.url + "?" + buildQueryString(filterToUse);
|
|
const response = props.requireAuth
|
|
? await authFetch(fetchUrl)
|
|
: await fetch(fetchUrl);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status + ": " + response.statusText);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (currentFetchId !== fetchIdRef) return;
|
|
|
|
if (isInitial) {
|
|
const elapsed = Date.now() - startTime;
|
|
if (elapsed < minLoadingDuration) {
|
|
await new Promise(resolve => setTimeout(resolve, minLoadingDuration - elapsed));
|
|
}
|
|
}
|
|
|
|
if (!props.remoteFiltering) {
|
|
const data = Array.isArray(result) ? result : (result.data || []);
|
|
setRemoteData(data);
|
|
} else {
|
|
setRemoteData(result.data || []);
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
Pagination: result.filter.Pagination,
|
|
OrderBy: result.filter.OrderBy,
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
if (currentFetchId !== fetchIdRef) return;
|
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
} finally {
|
|
if (currentFetchId === fetchIdRef) {
|
|
if (loadingTimerRef) {
|
|
clearTimeout(loadingTimerRef);
|
|
loadingTimerRef = null;
|
|
}
|
|
setInitialLoading(false);
|
|
setShowLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
createEffect(() => {
|
|
void props.refreshSignal;
|
|
if (isRemote()) {
|
|
fetchData(untrack(() => filter()), true);
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (!props.onFilterChange) return;
|
|
props.onFilterChange(filter());
|
|
});
|
|
|
|
const triggerFetch = (newFilter: AutoTableFilter) => {
|
|
if (debounceTimerRef) {
|
|
clearTimeout(debounceTimerRef);
|
|
debounceTimerRef = null;
|
|
}
|
|
fetchData(newFilter);
|
|
};
|
|
|
|
const handleSort = (identifier: string) => {
|
|
if (isLocalFiltering()) {
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
OrderBy: {
|
|
Identifier: identifier,
|
|
Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false,
|
|
},
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
}));
|
|
} else {
|
|
setFilter((prev) => {
|
|
const newFilter = {
|
|
...prev,
|
|
OrderBy: {
|
|
Identifier: identifier,
|
|
Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false,
|
|
},
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
};
|
|
triggerFetch(newFilter);
|
|
return newFilter;
|
|
});
|
|
}
|
|
};
|
|
|
|
const getSortIdentifier = (col: AutoTableColumn, colIndex: number): string | null => {
|
|
if (col.sortIdentifier) return col.sortIdentifier;
|
|
if (isLocalFiltering()) return "_col_" + colIndex;
|
|
return null;
|
|
};
|
|
|
|
const handlePageChange = (page: number) => {
|
|
if (isLocalFiltering()) {
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, CurrentPage: page },
|
|
}));
|
|
} else {
|
|
setFilter((prev) => {
|
|
const newFilter = {
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, CurrentPage: page },
|
|
};
|
|
triggerFetch(newFilter);
|
|
return newFilter;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleItemsPerPageChange = (itemsPerPage: number) => {
|
|
if (isLocalFiltering()) {
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 },
|
|
}));
|
|
} else {
|
|
setFilter((prev) => {
|
|
const newFilter = {
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 },
|
|
};
|
|
triggerFetch(newFilter);
|
|
return newFilter;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSearch = (identifier: string, value: string, exact = false) => {
|
|
const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => {
|
|
const existingIndex = searchArray.findIndex(s => s.Identifier === identifier);
|
|
let updated: AutoTableSearchEntry[];
|
|
if (existingIndex >= 0) {
|
|
updated = [...searchArray];
|
|
updated[existingIndex] = { Identifier: identifier, Values: [value], Exact: exact };
|
|
} else {
|
|
updated = [...searchArray, { Identifier: identifier, Values: [value], Exact: exact }];
|
|
}
|
|
return updated.filter(s => s.Values.some(v => v));
|
|
};
|
|
|
|
if (isLocalFiltering()) {
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
Search: updateSearchArray(prev.Search),
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
}));
|
|
} else {
|
|
const newSearchInputs = updateSearchArray(searchInputs());
|
|
setSearchInputs(newSearchInputs);
|
|
|
|
if (debounceTimerRef) clearTimeout(debounceTimerRef);
|
|
|
|
debounceTimerRef = setTimeout(() => {
|
|
debounceTimerRef = null;
|
|
setFilter((prev) => {
|
|
const newFilter = {
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
Search: updateSearchArray(prev.Search),
|
|
};
|
|
fetchData(newFilter);
|
|
return newFilter;
|
|
});
|
|
}, 150);
|
|
}
|
|
bumpSearchGeneration();
|
|
};
|
|
|
|
const readSearchValue = (identifier: string): string => {
|
|
if (isLocalFiltering()) {
|
|
return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || "";
|
|
}
|
|
return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || "";
|
|
};
|
|
|
|
const getSearchValue = (identifier: string): string => {
|
|
searchGeneration();
|
|
return readSearchValue(identifier);
|
|
};
|
|
|
|
const setSearchValue = (identifier: string, value: string) => {
|
|
handleSearch(identifier, value);
|
|
};
|
|
|
|
const setSearchValueExact = (identifier: string, value: string) => {
|
|
handleSearch(identifier, value, true);
|
|
};
|
|
|
|
const getSearchValues = (identifier: string): string[] => {
|
|
if (isLocalFiltering()) {
|
|
return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || [];
|
|
}
|
|
return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || [];
|
|
};
|
|
|
|
const setSearchValues = (identifier: string, values: string[]) => {
|
|
const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => {
|
|
const existingIndex = searchArray.findIndex(s => s.Identifier === identifier);
|
|
let updated: AutoTableSearchEntry[];
|
|
if (existingIndex >= 0) {
|
|
updated = [...searchArray];
|
|
updated[existingIndex] = { Identifier: identifier, Values: values };
|
|
} else {
|
|
updated = [...searchArray, { Identifier: identifier, Values: values }];
|
|
}
|
|
return updated.filter(s => s.Values.length > 0);
|
|
};
|
|
|
|
if (isLocalFiltering()) {
|
|
setFilter((prev) => ({
|
|
...prev,
|
|
Search: updateSearchArray(prev.Search),
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
}));
|
|
} else {
|
|
const newSearchInputs = updateSearchArray(searchInputs());
|
|
setSearchInputs(newSearchInputs);
|
|
|
|
if (debounceTimerRef) clearTimeout(debounceTimerRef);
|
|
|
|
debounceTimerRef = setTimeout(() => {
|
|
debounceTimerRef = null;
|
|
setFilter((prev) => {
|
|
const newFilter = {
|
|
...prev,
|
|
Pagination: { ...prev.Pagination, CurrentPage: 1 },
|
|
Search: updateSearchArray(prev.Search),
|
|
};
|
|
fetchData(newFilter);
|
|
return newFilter;
|
|
});
|
|
}, 150);
|
|
}
|
|
bumpSearchGeneration();
|
|
};
|
|
|
|
const getMultiSearchValue = (fields: string[]): string => {
|
|
return readSearchValue(createMultiSearchIdentifier(fields));
|
|
};
|
|
|
|
const setMultiSearchValue = (fields: string[], value: string) => {
|
|
handleSearch(createMultiSearchIdentifier(fields), value);
|
|
};
|
|
|
|
const getHeaderColorClass = () => HEADER_COLOR_CLS[opts().color];
|
|
const getHeaderSortHover = () => HEADER_SORT_HOVER_CLS[opts().color];
|
|
const getHeaderTextClass = () => HEADER_TEXT_CLS[opts().color];
|
|
const getHeaderDragClass = () => HEADER_DRAG_CLS[opts().color];
|
|
const getHeaderSortIconClass = () => HEADER_SORT_ICON_CLS[opts().color];
|
|
const getHeaderPaddingClass = () => HEADER_PADDING_CLS[opts().size];
|
|
const getBodyPaddingClass = () => BODY_PADDING_CLS[opts().size];
|
|
const getPaginationPaddingClass = () => PAGINATION_PADDING_CLS[opts().size];
|
|
const getRowHoverClass = () => opts().hover ? ROW_HOVER_CLS[opts().color] : "";
|
|
|
|
const handleDragStart = (e: DragEvent, orderIndex: number) => {
|
|
if (!opts().draggableColumns) return;
|
|
setDraggedColumn(orderIndex);
|
|
e.dataTransfer!.effectAllowed = "move";
|
|
e.dataTransfer!.setData("text/plain", orderIndex.toString());
|
|
};
|
|
|
|
const handleDragOver = (e: DragEvent, orderIndex: number) => {
|
|
if (!opts().draggableColumns || draggedColumn() === null) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = "move";
|
|
if (dragOverColumn() !== orderIndex) {
|
|
setDragOverColumn(orderIndex);
|
|
}
|
|
};
|
|
|
|
const handleDragLeave = () => {
|
|
setDragOverColumn(null);
|
|
};
|
|
|
|
const handleDrop = (e: DragEvent, targetOrderIndex: number) => {
|
|
if (!opts().draggableColumns || draggedColumn() === null) return;
|
|
e.preventDefault();
|
|
|
|
if (draggedColumn() !== targetOrderIndex) {
|
|
setColumnOrder(prev => {
|
|
const newOrder = [...prev];
|
|
const [removed] = newOrder.splice(draggedColumn() as number, 1);
|
|
newOrder.splice(targetOrderIndex, 0, removed);
|
|
return newOrder;
|
|
});
|
|
}
|
|
|
|
setDraggedColumn(null);
|
|
setDragOverColumn(null);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggedColumn(null);
|
|
setDragOverColumn(null);
|
|
};
|
|
|
|
// -- Summary row reordering (drag-to-reorder, via the grip handle) --------
|
|
const handleSummaryDragStart = (e: DragEvent, index: number) => {
|
|
setDraggedSummary(index);
|
|
e.dataTransfer!.effectAllowed = "move";
|
|
e.dataTransfer!.setData("text/plain", index.toString());
|
|
};
|
|
const handleSummaryDragOver = (e: DragEvent, index: number) => {
|
|
if (draggedSummary() === null) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = "move";
|
|
if (dragOverSummary() !== index) setDragOverSummary(index);
|
|
};
|
|
const handleSummaryDrop = (e: DragEvent, target: number) => {
|
|
if (draggedSummary() === null) return;
|
|
e.preventDefault();
|
|
const from = draggedSummary() as number;
|
|
if (from !== target) {
|
|
setUserSummaryRows((prev: UserSummaryRow[]) => {
|
|
const next = [...prev];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(target, 0, moved);
|
|
return next;
|
|
});
|
|
}
|
|
setDraggedSummary(null);
|
|
setDragOverSummary(null);
|
|
};
|
|
const handleSummaryDragEnd = () => { setDraggedSummary(null); setDragOverSummary(null); };
|
|
|
|
const resetColumnOrder = () => {
|
|
setColumnOrder(allColumnKeys());
|
|
};
|
|
const resetColumnWidths = () => setColumnWidths({});
|
|
const resetCalcColumns = () => setUserCalcColumns([]);
|
|
const resetSummaryRows = () => setUserSummaryRows([]);
|
|
const resetAll = () => {
|
|
resetCalcColumns();
|
|
resetSummaryRows();
|
|
resetColumnWidths();
|
|
resetColumnOrder();
|
|
};
|
|
// Show the reset menu only when there's something resettable enabled.
|
|
const canReset = () => opts().resetButton && (opts().draggableColumns || opts().resizableColumns || opts().calculatedColumns || opts().summaryRows);
|
|
|
|
const isLoading = () => initialLoading() || showLoading();
|
|
|
|
// The ordered, visible columns header & body iterate over. Built from the
|
|
// stable-key order (or natural order when not draggable); calc columns are
|
|
// included here only when merged (cellRenderer present). orderIndex is the
|
|
// position within the full order, so drag handlers splice columnOrder by it.
|
|
const displayColumnsWithIndices = createMemo(() => {
|
|
const byKey = columnByKey();
|
|
const keys = opts().draggableColumns ? columnOrder() : allColumnKeys();
|
|
const mapped = keys
|
|
.map((k, orderIdx) => {
|
|
const entry = byKey.get(k);
|
|
return entry ? { column: entry.column, originalIndex: entry.originalIndex, orderIndex: orderIdx, key: k } : null;
|
|
})
|
|
.filter(Boolean) as { column: AutoTableColumn; originalIndex: number; orderIndex: number; key: string }[];
|
|
|
|
if (!opts().toggleColumns) return mapped;
|
|
|
|
// Calc columns aren't part of the toggle set — always keep them.
|
|
return mapped.filter(({ column, originalIndex }) =>
|
|
column.calculated || column.toggleable === false || visibleColumns().includes(originalIndex.toString()));
|
|
});
|
|
|
|
// Columns shown: those in the ordered list plus calc columns appended when
|
|
// not merged into the order (rowRenderer case).
|
|
const renderedColumnCount = () => displayColumnsWithIndices().length + appendedCalcColumns().length;
|
|
const totalColumnCount = () => renderedColumnCount() + (opts().accordion ? 1 : 0);
|
|
|
|
// When every visible column has a pinned width, give the table an explicit
|
|
// px width equal to their sum. table-fixed then honors each width exactly, so
|
|
// resizing one column only moves that boundary (Excel-style) instead of
|
|
// reflowing the others; the table grows/scrolls as widths change. Returns
|
|
// null (→ min-w-full) until all columns are pinned, e.g. before first resize.
|
|
// The accordion toggle column is a fixed 40px (w-10).
|
|
const ACCORDION_TOGGLE_WIDTH = 40;
|
|
const pinnedTableWidth = createMemo(() => {
|
|
if (!opts().resizableColumns) return null;
|
|
const widths = columnWidths();
|
|
if (Object.keys(widths).length === 0) return null;
|
|
let sum = opts().accordion ? ACCORDION_TOGGLE_WIDTH : 0;
|
|
for (const c of displayColumnsWithIndices()) {
|
|
const w = widths[c.key];
|
|
if (w == null) return null;
|
|
sum += w;
|
|
}
|
|
for (const c of appendedCalcColumns()) {
|
|
const w = c.sortIdentifier ? widths[c.sortIdentifier] : undefined;
|
|
if (w == null) return null;
|
|
sum += w;
|
|
}
|
|
return sum;
|
|
});
|
|
|
|
const hasCalculatedColumns = createMemo(() => props.columns.some(c => !!c.calculated) || userCalcColumns().length > 0);
|
|
|
|
// Calculated columns are rendered by AutoTable itself (the cellRenderer never
|
|
// sees them). Numeric results read best right-aligned, so default to that.
|
|
const renderCalculatedCell = (col: AutoTableColumn, item: any) => {
|
|
const pos = col.displayPosition ?? COL_POS_RIGHT;
|
|
return <td class={POS_CLS[pos]}>{formatCalculatedValue(item, col.calculated!)}</td>;
|
|
};
|
|
|
|
const columnToggleOptions = createMemo(() => {
|
|
return props.columns
|
|
.map((col, i) => ({ value: i.toString(), label: col.displayName, toggleable: col.toggleable }))
|
|
.filter(opt => opt.toggleable !== false);
|
|
});
|
|
|
|
const handleExportCSV = async (): Promise<void> => {
|
|
if (isExporting()) return;
|
|
setIsExporting(true);
|
|
|
|
try {
|
|
const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];
|
|
|
|
if (isLocalFiltering()) {
|
|
const filterWithoutPagination: AutoTableFilter = {
|
|
...filter(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
|
|
};
|
|
const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
|
|
downloadCSV(processed.data, exportColumns, effectiveExportFilename());
|
|
} else if (props.url && props.remoteFiltering) {
|
|
const exportFilter: AutoTableFilter = {
|
|
...filter(),
|
|
Search: searchInputs(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
|
|
};
|
|
const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
|
|
const response = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);
|
|
|
|
if (!response.ok) {
|
|
throw new Error("HTTP " + response.status + ": " + response.statusText);
|
|
}
|
|
|
|
const result = await response.json();
|
|
downloadCSV(result.data || [], exportColumns, effectiveExportFilename());
|
|
}
|
|
} catch (err) {
|
|
console.error("Export failed:", err);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const handleExportPDF = async () => {
|
|
if (isExporting()) return;
|
|
setIsExporting(true);
|
|
|
|
try {
|
|
const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];
|
|
|
|
if (isLocalFiltering()) {
|
|
const filterWithoutPagination = {
|
|
...filter(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
|
|
};
|
|
const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
|
|
await downloadPDF(processed.data, exportColumns, effectiveExportFilename(), effectivePdfHeader(), null, pdfSummaries());
|
|
} else if (props.url && props.remoteFiltering) {
|
|
const exportFilter = {
|
|
...filter(),
|
|
Search: searchInputs(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
|
|
};
|
|
const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
|
|
const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);
|
|
|
|
if (!resp.ok) {
|
|
throw new Error("HTTP " + resp.status + ": " + resp.statusText);
|
|
}
|
|
|
|
const result = await resp.json();
|
|
await downloadPDF(result.data || [], exportColumns, effectiveExportFilename(), effectivePdfHeader(), result, pdfSummaries());
|
|
}
|
|
} catch (err) {
|
|
console.error("Export failed:", err);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const handlePrintPDF = async () => {
|
|
if (isExporting()) return;
|
|
setIsExporting(true);
|
|
|
|
try {
|
|
const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()];
|
|
let pdfBytes = null;
|
|
|
|
if (isLocalFiltering()) {
|
|
const filterWithoutPagination = {
|
|
...filter(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 },
|
|
};
|
|
const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns);
|
|
pdfBytes = await buildTablePDF(processed.data, exportColumns, effectivePdfHeader(), null, pdfSummaries());
|
|
} else if (props.url && props.remoteFiltering) {
|
|
const exportFilter = {
|
|
...filter(),
|
|
Search: searchInputs(),
|
|
Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 },
|
|
};
|
|
const fetchUrl = props.url + "?" + buildQueryString(exportFilter);
|
|
const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl);
|
|
|
|
if (!resp.ok) {
|
|
throw new Error("HTTP " + resp.status + ": " + resp.statusText);
|
|
}
|
|
|
|
const result = await resp.json();
|
|
pdfBytes = await buildTablePDF(result.data || [], exportColumns, effectivePdfHeader(), result, pdfSummaries());
|
|
}
|
|
|
|
if (pdfBytes) {
|
|
const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" });
|
|
const url = URL.createObjectURL(blob);
|
|
const iframe = document.createElement("iframe");
|
|
iframe.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;border:none;opacity:0;pointer-events:none";
|
|
iframe.src = url;
|
|
iframe.onload = () => {
|
|
iframe.contentWindow!.focus();
|
|
iframe.contentWindow!.print();
|
|
};
|
|
document.body.appendChild(iframe);
|
|
}
|
|
} catch (err) {
|
|
console.error("PDF print failed:", err);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const showExportMenu = () => opts().exportCSV || !!props.exportMenuItems;
|
|
|
|
const showToolbar = () => opts().toggleColumns || showExportMenu() || opts().calculatedColumns || opts().summaryRows || !!props.toolbarActions;
|
|
// Actions are only "on the filter row" when a filter row exists. Without
|
|
// searchFields there's nothing to push them right, so fall through to
|
|
// TOOLBAR_ACTIONS (lg:ml-auto) instead of the inline variant and keep the
|
|
// toolbar (e.g. a lone Export button) right-aligned.
|
|
const actionsOnFilterRow = () => !!props.searchFields && (opts().inlineToolbar || showToolbar());
|
|
const toolbarActionsCls = () => actionsOnFilterRow() ? TOOLBAR_ACTIONS_INLINE : TOOLBAR_ACTIONS;
|
|
const desktopRowCls = () => TOOLBAR_DESKTOP_ROW;
|
|
const panelActionsCls = () => actionsOnFilterRow() ? PANEL_ACTIONS_INLINE : PANEL_ACTIONS_DEFAULT;
|
|
|
|
const resolveExportMenuItems = () => {
|
|
const items = props.exportMenuItems;
|
|
if (items == null) return null;
|
|
return typeof items === "function" ? items({ allFilteredData }) : items;
|
|
};
|
|
|
|
const toolbarButtons = () => showToolbar() ? (
|
|
<div class={toolbarActionsCls()}>
|
|
{props.toolbarActions && props.toolbarActions({ allFilteredData })}
|
|
<Show when={opts().calculatedColumns || opts().summaryRows}>
|
|
<Popover placement="bottom-end">
|
|
<PopoverTrigger class={CALC_ADD_TRIGGER_CLS}>
|
|
<Icon icon="plus" size={16} style={{ display: "inline", marginRight: "4px" }} />
|
|
Add Calculation
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<AddCalcMenu
|
|
allowColumn={opts().calculatedColumns}
|
|
allowSummary={opts().summaryRows}
|
|
operandOptions={calcOperandOptions}
|
|
onSaveColumn={saveCalcColumn}
|
|
onSaveSummary={saveSummaryRow}
|
|
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
<Show when={opts().toggleColumns}>
|
|
<FormMultiSelectTrigger
|
|
trigger={<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
|
|
<Icon icon="table-columns" size={16} style={{ display: "inline", marginRight: "4px" }} />
|
|
Columns
|
|
</ButtonUI>}
|
|
options={columnToggleOptions()}
|
|
value={visibleColumns()}
|
|
onchange={setVisibleColumns}
|
|
align="right"
|
|
minWidth={180}
|
|
showSelectAll={true}
|
|
searchable={true}
|
|
small={true}
|
|
/>
|
|
</Show>
|
|
<Show when={opts().exportCSV && opts().userCustomizeExport}>
|
|
<Popover placement="bottom-end">
|
|
<PopoverTrigger class={CALC_ADD_TRIGGER_CLS} title="Export options">
|
|
<Icon icon="cog" size={16} style={{ display: "inline", marginRight: "4px" }} />
|
|
Export Options
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<div class="flex flex-col gap-3 p-4 w-72">
|
|
<div class="text-sm font-semibold text-ink">Export settings</div>
|
|
<div>
|
|
<FormLabel>File name</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="export"
|
|
value={exportFilenameInput()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setExportFilenameInput(e.currentTarget.value)} />
|
|
</div>
|
|
<div>
|
|
<FormLabel>PDF title</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="(none)"
|
|
value={pdfTitle()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfTitle(e.currentTarget.value)} />
|
|
</div>
|
|
<div>
|
|
<FormLabel>PDF subtitle</FormLabel>
|
|
<FormInput small={true} class="w-full" placeholder="(none)"
|
|
value={pdfSubtitle()}
|
|
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfSubtitle(e.currentTarget.value)} />
|
|
</div>
|
|
<div>
|
|
<FormLabel>Orientation</FormLabel>
|
|
<FormSelect small={true} class="w-full"
|
|
value={pdfOrientation()}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPdfOrientation(e.currentTarget.value)}>
|
|
<option value={String(PDF_ORIENTATION_LANDSCAPE)}>Landscape</option>
|
|
<option value={String(PDF_ORIENTATION_PORTRAIT)}>Portrait</option>
|
|
</FormSelect>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-sm text-ink cursor-pointer">
|
|
<input type="checkbox" checked={pdfShowDate()} onchange={(e: Event & { currentTarget: HTMLInputElement }) => setPdfShowDate(e.currentTarget.checked)} />
|
|
Show date
|
|
</label>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
<Show when={showExportMenu()}>
|
|
<Menu placement="bottom-end">
|
|
<MenuTrigger>
|
|
<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} disabled={isExporting()}>
|
|
<Icon icon="download" size={16} style={{ display: "inline", marginRight: "4px" }} />
|
|
Export
|
|
</ButtonUI>
|
|
</MenuTrigger>
|
|
<MenuContent>
|
|
<Show when={opts().exportCSV || props.exportMenuItems}>
|
|
<MenuSection>Spreadsheet</MenuSection>
|
|
</Show>
|
|
<Show when={opts().exportCSV}>
|
|
<MenuItem
|
|
icon="download"
|
|
onclick={(_e: MouseEvent) => handleExportCSV()}
|
|
disabled={isExporting()}
|
|
>
|
|
{isExporting() ? "Exporting..." : "Export CSV"}
|
|
</MenuItem>
|
|
</Show>
|
|
<Show when={!!resolveExportMenuItems()}>
|
|
{resolveExportMenuItems()}
|
|
</Show>
|
|
<Show when={opts().exportCSV}>
|
|
<MenuSection>PDF</MenuSection>
|
|
<MenuItem
|
|
icon="download"
|
|
onclick={(_e: MouseEvent) => handleExportPDF()}
|
|
disabled={isExporting()}
|
|
>
|
|
{isExporting() ? "Exporting..." : "Export PDF"}
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon="print"
|
|
onclick={(_e: MouseEvent) => handlePrintPDF()}
|
|
disabled={isExporting()}
|
|
>
|
|
{isExporting() ? "Loading..." : "Print PDF"}
|
|
</MenuItem>
|
|
</Show>
|
|
</MenuContent>
|
|
</Menu>
|
|
</Show>
|
|
</div>
|
|
) : null;
|
|
|
|
// Main render
|
|
|
|
const searchFieldsCtx: SearchFieldsContext = {
|
|
getSearchValue, setSearchValue, setSearchValueExact,
|
|
getSearchValues, setSearchValues, getMultiSearchValue,
|
|
setMultiSearchValue, resetColumnOrder,
|
|
};
|
|
|
|
const [filtersOpen, setFiltersOpen] = createSignal(false);
|
|
const activeFilterCount = createMemo(() => {
|
|
const searches = isLocalFiltering() ? filter().Search : searchInputs();
|
|
return searches.filter((s: AutoTableSearchEntry) => s.Values.some(v => v)).length;
|
|
});
|
|
const toggleFilters = () => setFiltersOpen(v => !v);
|
|
const inlineFiltersBodyCls = () => {
|
|
let cls = actionsOnFilterRow() ? FILTERS_BODY_INLINE : FILTERS_BODY_BASE + " " + FILTERS_BODY_DESKTOP_FLEX;
|
|
cls += filtersOpen() ? " max-lg:flex" : " max-lg:hidden";
|
|
return cls;
|
|
};
|
|
const asideFiltersBodyCls = () =>
|
|
FILTERS_BODY_ASIDE + (filtersOpen() ? " max-lg:flex" : " max-lg:hidden") + " lg:flex";
|
|
|
|
const filtersToggleBtn = <button
|
|
type="button"
|
|
class={FILTERS_TOGGLE}
|
|
onclick={toggleFilters}
|
|
aria-expanded={filtersOpen()}
|
|
>
|
|
<span class="flex items-center gap-2">
|
|
Filters
|
|
<Show when={activeFilterCount() > 0}>
|
|
<span class={FILTERS_BADGE}>{activeFilterCount()}</span>
|
|
</Show>
|
|
</span>
|
|
<Icon
|
|
icon="chevron-down"
|
|
size={16}
|
|
class={"shrink-0 transition-transform " + (filtersOpen() ? "rotate-180" : "")}
|
|
/>
|
|
</button>;
|
|
|
|
const toolbarPanelActions = () => showToolbar() ? (
|
|
<div class={panelActionsCls()}>
|
|
{toolbarButtons()}
|
|
</div>
|
|
) : null;
|
|
|
|
return <div class="min-w-0 w-full max-w-full">
|
|
<Show when={(props.searchFields && !opts().searchAside) || toolbarButtons()}>
|
|
<div class={TOOLBAR}>
|
|
<div class={TOOLBAR_MOBILE_BAR}>
|
|
<Show when={props.searchFields && !opts().searchAside}>
|
|
{filtersToggleBtn}
|
|
</Show>
|
|
</div>
|
|
<div class={desktopRowCls()}>
|
|
<Show when={props.searchFields && !opts().searchAside}>
|
|
<div class={inlineFiltersBodyCls()}>
|
|
{untrack(() => props.searchFields!(searchFieldsCtx))}
|
|
{toolbarPanelActions()}
|
|
</div>
|
|
</Show>
|
|
<Show when={!props.searchFields && showToolbar()}>
|
|
<div class="hidden lg:contents">
|
|
{toolbarButtons()}
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
<div class={opts().searchAside ? "flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-start" : ""}>
|
|
<Show when={opts().searchAside && props.searchFields}>
|
|
<aside class={SEARCH_CARD + " flex flex-col gap-3"}>
|
|
{filtersToggleBtn}
|
|
<div class={asideFiltersBodyCls()}>
|
|
{untrack(() => props.searchFields!(searchFieldsCtx))}
|
|
{toolbarPanelActions()}
|
|
</div>
|
|
</aside>
|
|
</Show>
|
|
<div class={opts().searchAside ? "flex-1 min-w-0" : ""}>
|
|
{props.aboveTable}
|
|
<div class={TBL_CONTAINER
|
|
+ (opts().surroundingBorder ? " border border-line-strong" : "")
|
|
+ (opts().shadow ? " shadow-sm" : "")}>
|
|
<div class={TBL_WRAPPER}>
|
|
<table
|
|
style={pinnedTableWidth() != null ? { width: pinnedTableWidth() + "px" } : undefined}
|
|
class={"border-collapse" + (pinnedTableWidth() != null ? "" : " " + TBL_BASE) + (opts().tableLayoutAuto ? "" : " table-fixed")}
|
|
>
|
|
<thead class="[&_th]:border-b [&_th]:border-line-strong">
|
|
<tr>
|
|
<Show when={opts().accordion}>
|
|
<th class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + ACCORDION_TOGGLE_TH} />
|
|
</Show>
|
|
<Show when={renderedColumnCount() === 0}>
|
|
<th class={getHeaderPaddingClass() + " " + getHeaderColorClass()}> </th>
|
|
</Show>
|
|
<For each={displayColumnsWithIndices()}>
|
|
{({ column: col, originalIndex: originalIdx, orderIndex: orderIdx, key: colKey }, displayIdx) => {
|
|
const isDragging = () => draggedColumn() === orderIdx;
|
|
const isDragOver = () => dragOverColumn() === orderIdx && draggedColumn() !== orderIdx;
|
|
const pos = col.displayPosition ?? COL_POS_LEFT;
|
|
const posCls = POS_CLS[pos];
|
|
const headerInnerPosCls = HEADER_INNER_POS[pos];
|
|
|
|
return <th
|
|
data-colkey={colKey}
|
|
draggable={opts().draggableColumns && resizingColumn() === null}
|
|
onDragStart={(e) => handleDragStart(e, orderIdx)}
|
|
onDragOver={(e) => handleDragOver(e, orderIdx)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={(e) => handleDrop(e, orderIdx)}
|
|
onDragEnd={handleDragEnd}
|
|
style={columnWidthStyle(colKey)}
|
|
class={getHeaderPaddingClass() + " " + getHeaderColorClass()
|
|
+ (opts().resizableColumns ? " relative" : "")
|
|
+ (posCls ? " " + posCls : "")
|
|
+ (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "")
|
|
+ (col.sortable ? " cursor-pointer" : "")
|
|
+ (col.sortable && !resizingColumn() ? " " + getHeaderSortHover() : "")
|
|
+ (opts().draggableColumns ? " select-none cursor-grab active:cursor-grabbing" : "")
|
|
+ (isDragOver() ? "outline-2 outline-sky-500 -outline-offset-2" : "")
|
|
+ (isColHighlighted(col) ? "outline-2 -outline-offset-2 outline-amber-400" : "")
|
|
+ (col.headerClasses ? " " + col.headerClasses : "")}
|
|
onclick={() => {
|
|
if (col.sortable) {
|
|
const sortId = getSortIdentifier(col, originalIdx);
|
|
if (sortId) handleSort(sortId);
|
|
}
|
|
}}
|
|
>
|
|
<Show when={opts().resizableColumns && !(appendedCalcColumns().length === 0 && displayIdx() === displayColumnsWithIndices().length - 1)}>
|
|
<div class={RESIZE_HANDLE_CLS}
|
|
onMouseDown={(e: MouseEvent) => startColumnResize(e, colKey)}
|
|
onclick={(e: MouseEvent) => e.stopPropagation()}></div>
|
|
</Show>
|
|
<div class={HEADER_CONTENT + (isDragging() ? " scale-95" : "")}>
|
|
<div class={HEADER_INNER_BASE + (headerInnerPosCls ? " " + headerInnerPosCls : "")}>
|
|
<Show when={opts().draggableColumns}>
|
|
<span class={DRAG_HANDLE + " " + getHeaderDragClass()}>
|
|
<Icon icon="grip-vertical" size={14} />
|
|
</span>
|
|
</Show>
|
|
<Show when={!!col.calculated}>
|
|
<span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
|
<Popover placement="bottom-end">
|
|
<PopoverTrigger
|
|
class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
|
|
title="Edit calculation">
|
|
<Icon icon="pen-to-square" size={13} />
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<CalculatedColumnForm
|
|
column={() => calcById().get((col.sortIdentifier || "").slice(CALC_REF_PREFIX.length)) ?? null}
|
|
operandOptions={calcOperandOptions}
|
|
onSave={saveCalcColumn}
|
|
onRemove={removeCalcColumn}
|
|
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</span>
|
|
</Show>
|
|
<div class={"grow text-sm " + getHeaderTextClass()}>
|
|
{col.displayName}
|
|
</div>
|
|
<Show when={col.sortable}>
|
|
<div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
|
|
<Show when={filter().OrderBy.Identifier && filter().OrderBy.Identifier === getSortIdentifier(col, originalIdx)}>
|
|
<Show when={filter().OrderBy.Descending}
|
|
fallback={<Icon icon="caret-up" size={16} solid={true} />}>
|
|
<Icon icon="caret-down" size={16} solid={true} />
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</th>;
|
|
}}
|
|
</For>
|
|
<For each={calcMerged() ? [] : userCalcColumns()}>
|
|
{(uc: UserCalculatedColumn, calcIdx) => {
|
|
const sortId = "_calc_" + uc.id;
|
|
const sortable = () => isLocalFiltering();
|
|
const pos = uc.displayPosition ?? COL_POS_RIGHT;
|
|
const isLastColumn = () => calcIdx() === userCalcColumns().length - 1;
|
|
return <th
|
|
data-colkey={sortId}
|
|
style={columnWidthStyle(sortId)}
|
|
class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + POS_CLS[pos]
|
|
+ (opts().resizableColumns ? " relative" : "")
|
|
+ (opts().headerBorderY ? " border-l border-l-neutral-300" : "")
|
|
+ (highlightedCols().includes(sortId) ? "outline-2 -outline-offset-2 outline-amber-400" : "")
|
|
+ (sortable() ? " cursor-pointer" : "")
|
|
+ (sortable() && !resizingColumn() ? " " + getHeaderSortHover() : "")}
|
|
onclick={() => { if (sortable()) handleSort(sortId); }}>
|
|
<Show when={opts().resizableColumns && !isLastColumn()}>
|
|
<div class={RESIZE_HANDLE_CLS}
|
|
onMouseDown={(e: MouseEvent) => startColumnResize(e, sortId)}
|
|
onclick={(e: MouseEvent) => e.stopPropagation()}></div>
|
|
</Show>
|
|
<div class={HEADER_CONTENT}>
|
|
<div class={HEADER_INNER_BASE + " " + HEADER_INNER_POS[pos]}>
|
|
<span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
|
<Popover placement="bottom-end">
|
|
<PopoverTrigger
|
|
class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
|
|
title="Edit calculation">
|
|
<Icon icon="pen-to-square" size={13} />
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<CalculatedColumnForm
|
|
column={() => uc}
|
|
operandOptions={calcOperandOptions}
|
|
onSave={saveCalcColumn}
|
|
onRemove={removeCalcColumn}
|
|
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</span>
|
|
<div class={"grow text-sm " + getHeaderTextClass()}>
|
|
{uc.displayName}
|
|
</div>
|
|
<Show when={sortable()}>
|
|
<div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
|
|
<Show when={filter().OrderBy.Identifier === sortId}>
|
|
<Show when={filter().OrderBy.Descending}
|
|
fallback={<Icon icon="caret-up" size={16} solid={true} />}>
|
|
<Icon icon="caret-down" size={16} solid={true} />
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</th>;
|
|
}}
|
|
</For>
|
|
</tr>
|
|
</thead>
|
|
<tbody class={getBodyPaddingClass() + (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-line-strong" : "")}>
|
|
<Show when={isLoading()}>
|
|
<For each={Array.from({ length: 5 })}>
|
|
{(_, rowIdx) => <tr class={opts().alternate && rowIdx() % 2 === 1 ? "bg-surface-raised" : ""}>
|
|
<Show when={opts().accordion}>
|
|
<td></td>
|
|
</Show>
|
|
<For each={[...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]}>
|
|
{() => <td>
|
|
<div
|
|
class={SKELETON}
|
|
style={{ width: (60 + Math.random() * 30) + "%" }}
|
|
></div>
|
|
</td>}
|
|
</For>
|
|
</tr>}
|
|
</For>
|
|
</Show>
|
|
<Show when={!isLoading() && error()}>
|
|
<tr>
|
|
<td colspan={totalColumnCount() || 1} class={ERROR_CELL}>
|
|
Error: {error()}
|
|
</td>
|
|
</tr>
|
|
</Show>
|
|
<Show when={!isLoading() && !error() && renderedColumnCount() === 0}>
|
|
<tr>
|
|
<td colspan={totalColumnCount() || 1} class={EMPTY_CELL}>
|
|
No columns selected.
|
|
</td>
|
|
</tr>
|
|
</Show>
|
|
<Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length === 0}>
|
|
<tr>
|
|
<td colspan={totalColumnCount()}>
|
|
{(typeof props.emptyMessage === "function" ? props.emptyMessage() : props.emptyMessage) || "No entries found."}
|
|
</td>
|
|
</tr>
|
|
</Show>
|
|
<Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length > 0}>
|
|
<For each={displayData()}>
|
|
{(item, rowIdx) => {
|
|
const position = () => (displayPagination().CurrentPage - 1) * displayPagination().MaxItemsPerPage + rowIdx() + 1;
|
|
const isLastRow = () => rowIdx() === displayData().length - 1;
|
|
const rowKey = () => item.id ?? rowIdx();
|
|
const isExpanded = () => opts().accordion && expandedRows().has(rowKey());
|
|
const isHighlighted = () => !!(props.highlightMatch && props.highlightMatch(item));
|
|
return [
|
|
<tr
|
|
class={(() => {
|
|
let c = "";
|
|
if (isHighlighted()) {
|
|
// Highlight overrides alternate + hover with !important.
|
|
c += "bg-amber-100 dark:bg-amber-950/50! [&>td:first-child]:shadow-[inset_3px_0_0_var(--color-amber-500)] ";
|
|
} else {
|
|
if (opts().alternate && rowIdx() % 2 === 1) c += "bg-surface-raised ";
|
|
c += getRowHoverClass() + " ";
|
|
}
|
|
if (isExpanded() || (opts().borderX && !isLastRow())) c += "border-b border-line-strong ";
|
|
if (opts().accordion) c += "cursor-pointer select-none ";
|
|
return c.trim();
|
|
})()}
|
|
onclick={opts().accordion ? () => toggleAccordion(rowKey()) : undefined}
|
|
>
|
|
<Show when={opts().accordion}>
|
|
<td class={ACCORDION_TOGGLE_TD}>
|
|
<span class={ACCORDION_ICON + (isExpanded() ? " rotate-90" : "")}>
|
|
<Icon icon="chevron-right" size={14} />
|
|
</span>
|
|
</td>
|
|
</Show>
|
|
<Show when={(opts().draggableColumns || opts().toggleColumns || opts().accordion || opts().resizableColumns || hasCalculatedColumns()) && props.cellRenderer}
|
|
fallback={props.rowRenderer ? props.rowRenderer(item, position(), rowIdx()) : null}
|
|
>
|
|
<For each={displayColumnsWithIndices()}>
|
|
{({ column: col, originalIndex: originalIdx }) => {
|
|
if (col.calculated) return renderCalculatedCell(col, item);
|
|
return props.cellRenderer!(item, col, originalIdx, position(), rowIdx());
|
|
}}
|
|
</For>
|
|
</Show>
|
|
<For each={appendedCalcColumns()}>
|
|
{(col: AutoTableColumn) => renderCalculatedCell(col, item)}
|
|
</For>
|
|
</tr>,
|
|
<Show when={isExpanded() && props.accordionRenderer}>
|
|
<tr class={opts().borderX && !isLastRow() ? "border-b border-line-strong" : ""}>
|
|
<td colspan={totalColumnCount()}>
|
|
{props.accordionRenderer!(item, item[accordionKey()])}
|
|
</td>
|
|
</tr>
|
|
</Show>
|
|
];
|
|
}}
|
|
</For>
|
|
</Show>
|
|
</tbody>
|
|
<Show when={opts().summaryRows && userSummaryRows().length > 0}>
|
|
<tfoot class={getBodyPaddingClass()
|
|
+ " bg-surface-muted text-ink"
|
|
+ " [&_tr:first-child_td]:border-t [&_tr:first-child_td]:border-line-strong [&_tr:not(:first-child)_td]:border-t [&_tr:not(:first-child)_td]:border-line"
|
|
+ (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-line-strong" : "")}>
|
|
<For each={userSummaryRows()}>
|
|
{(s: UserSummaryRow, sIdx: () => number) => {
|
|
const value = () => computeSummaryValue(s);
|
|
// Always right-aligned: the value sits in the last column and the label
|
|
// in the cell immediately to its left (or they share the one cell in a
|
|
// single-column table). All other cells stay empty.
|
|
const cols = () => [...displayColumnsWithIndices().map((e: { column: AutoTableColumn }) => e.column), ...appendedCalcColumns()];
|
|
const valueIdx = () => cols().length - 1;
|
|
const labelIdx = () => { const v = valueIdx(); return v > 0 ? v - 1 : v; };
|
|
return <tr
|
|
class={(draggedSummary() === sIdx() ? "opacity-50 " : "") + (dragOverSummary() === sIdx() && draggedSummary() !== sIdx() ? "bg-surface-raised" : "")}
|
|
onDragOver={(e: DragEvent) => handleSummaryDragOver(e, sIdx())}
|
|
onDragLeave={() => setDragOverSummary(null)}
|
|
onDrop={(e: DragEvent) => handleSummaryDrop(e, sIdx())}>
|
|
<Show when={opts().accordion}><td></td></Show>
|
|
<For each={cols()}>
|
|
{(_col: AutoTableColumn, idx: () => number) => {
|
|
const isLbl = () => idx() === labelIdx();
|
|
const isVal = () => idx() === valueIdx();
|
|
const isFirst = () => idx() === 0;
|
|
return <td class={isFirst() ? "text-left" : "text-right"}>
|
|
<Show when={isFirst() && userSummaryRows().length > 1}>
|
|
<button type="button" draggable={true}
|
|
class="inline-flex items-center cursor-grab active:cursor-grabbing text-ink-faint hover:text-ink-muted bg-transparent border-0 p-0 leading-none align-middle"
|
|
title="Drag to reorder"
|
|
onDragStart={(e: DragEvent) => handleSummaryDragStart(e, sIdx())}
|
|
onDragEnd={handleSummaryDragEnd}>
|
|
<Icon icon="grip-vertical" size={12} />
|
|
</button>
|
|
</Show>
|
|
<Show when={isLbl() || isVal()}>
|
|
<div class="flex items-center justify-end gap-2 whitespace-nowrap">
|
|
<Show when={isLbl()}>
|
|
<Popover placement="bottom-end">
|
|
<PopoverTrigger class="inline-flex items-center gap-1 text-ink hover:text-ink-soft cursor-pointer bg-transparent border-0 p-0 font-bold">
|
|
{s.label}
|
|
<Icon icon="pen-to-square" size={11} class="opacity-50" />
|
|
</PopoverTrigger>
|
|
<PopoverContent>
|
|
<SummaryRowForm
|
|
row={() => s}
|
|
operandOptions={calcOperandOptions}
|
|
onSave={saveSummaryRow}
|
|
onRemove={removeSummaryRow}
|
|
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} />
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Show>
|
|
<Show when={isVal()}>
|
|
<span>{value()}</span>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</td>;
|
|
}}
|
|
</For>
|
|
</tr>;
|
|
}}
|
|
</For>
|
|
</tfoot>
|
|
</Show>
|
|
</table>
|
|
</div>
|
|
|
|
<Show when={!opts().hidePagination || canReset()}>
|
|
<div class={PAGINATION_BASE + " " + getPaginationPaddingClass()}>
|
|
<div class="flex items-center">
|
|
<Show when={canReset()}>
|
|
<div class="mr-4">
|
|
<Menu placement="top-start">
|
|
<MenuTrigger>
|
|
<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
|
|
<Icon icon="arrow-rotate-left" size={16} style={{ display: "inline", marginRight: "4px" }} />
|
|
Reset
|
|
</ButtonUI>
|
|
</MenuTrigger>
|
|
<MenuContent>
|
|
<Show when={opts().draggableColumns}>
|
|
<MenuItem icon="table-columns" onclick={(_e: MouseEvent) => resetColumnOrder()}>Reset column order</MenuItem>
|
|
</Show>
|
|
<Show when={opts().resizableColumns}>
|
|
<MenuItem icon="ruler-horizontal" onclick={(_e: MouseEvent) => resetColumnWidths()}>Reset column widths</MenuItem>
|
|
</Show>
|
|
<Show when={opts().calculatedColumns}>
|
|
<MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetCalcColumns()}>Reset calculations</MenuItem>
|
|
</Show>
|
|
<Show when={opts().summaryRows}>
|
|
<MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetSummaryRows()}>Reset summaries</MenuItem>
|
|
</Show>
|
|
<MenuDivider />
|
|
<MenuItem icon="arrows-rotate" onclick={(_e: MouseEvent) => resetAll()}>Reset all</MenuItem>
|
|
</MenuContent>
|
|
</Menu>
|
|
</div>
|
|
</Show>
|
|
<div class={PAGINATION_INFO}>
|
|
<Show when={!opts().hidePagination}>
|
|
<b class="leading-none">
|
|
<Icon icon="list-ol" size={16} />
|
|
</b>
|
|
<span class="ml-3">
|
|
{displayPagination().ViewRangeLower}-{displayPagination().ViewRangeUpper} of {displayPagination().TotalItems}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
<div class={PAGINATION_CONTROLS}>
|
|
<Show when={!opts().hidePagination}>
|
|
<div class={PAGINATION_LABEL}>
|
|
Items per page:
|
|
</div>
|
|
<FormSelect
|
|
small={true}
|
|
class="mr-5"
|
|
value={displayPagination().MaxItemsPerPage}
|
|
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => handleItemsPerPageChange(parseInt(e.currentTarget.value))}
|
|
>
|
|
<option value="5">5</option>
|
|
<option value="10">10</option>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
<Show when={opts().paginationShowAll}>
|
|
<option value="-1">All</option>
|
|
</Show>
|
|
</FormSelect>
|
|
|
|
<PaginationButton
|
|
onclick={() => handlePageChange(1)}
|
|
disabled={displayPagination().CurrentPage <= 1}
|
|
>
|
|
<Icon icon="angles-left" size={16} />
|
|
</PaginationButton>
|
|
<PaginationButton
|
|
onclick={() => handlePageChange(displayPagination().CurrentPage - 1)}
|
|
disabled={displayPagination().CurrentPage <= 1}
|
|
>
|
|
<Icon icon="chevron-left" size={16} />
|
|
</PaginationButton>
|
|
|
|
<div class={PAGINATION_PAGE}>
|
|
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
|
|
</div>
|
|
|
|
<PaginationButton
|
|
onclick={() => handlePageChange(displayPagination().CurrentPage + 1)}
|
|
disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
|
|
>
|
|
<Icon icon="chevron-right" size={16} />
|
|
</PaginationButton>
|
|
<PaginationButton
|
|
onclick={() => handlePageChange(displayPagination().TotalPages)}
|
|
disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
|
|
>
|
|
<Icon icon="angles-right" size={16} />
|
|
</PaginationButton>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
{props.belowTable}
|
|
</div>
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
|
|
function PaginationButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
return <button
|
|
class={PAGINATION_BTN}
|
|
onclick={props.onclick}
|
|
disabled={props.disabled}
|
|
>
|
|
{props.children}
|
|
</button>;
|
|
}
|
|
|
|
interface TdProps {
|
|
class?: string;
|
|
style?: string;
|
|
children: any;
|
|
}
|
|
|
|
export function TdLeft(props: TdProps) {
|
|
return <td class={"text-left " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export function TdRight(props: TdProps) {
|
|
return <td class={"text-right " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export function TdCenter(props: TdProps) {
|
|
return <td class={"text-center " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export function TdUniformLeft(props: TdProps) {
|
|
return <td class={"text-left " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export function TdUniformRight(props: TdProps) {
|
|
return <td class={"text-right " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export function TdUniformCenter(props: TdProps) {
|
|
return <td class={"text-center " + (props.class || "")} style={props.style}>{props.children}</td>;
|
|
}
|
|
|
|
export default AutoTable;
|