kokotable 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/contract/table-request.schema.json +424 -0
- package/dist/core/src/adapters/sources.d.ts +59 -0
- package/dist/core/src/adapters/sources.js +235 -0
- package/dist/core/src/client/aggregate.d.ts +94 -0
- package/dist/core/src/client/aggregate.js +222 -0
- package/dist/core/src/client/groupRows.d.ts +40 -0
- package/dist/core/src/client/groupRows.js +145 -0
- package/dist/core/src/client/runClientQuery.d.ts +67 -0
- package/dist/core/src/client/runClientQuery.js +157 -0
- package/dist/core/src/columns/ColumnBuilder.d.ts +121 -0
- package/dist/core/src/columns/ColumnBuilder.js +273 -0
- package/dist/core/src/columns/cascade.d.ts +24 -0
- package/dist/core/src/columns/cascade.js +93 -0
- package/dist/core/src/columns/deriveOperators.d.ts +38 -0
- package/dist/core/src/columns/deriveOperators.js +94 -0
- package/dist/core/src/columns/expandSorting.d.ts +19 -0
- package/dist/core/src/columns/expandSorting.js +52 -0
- package/dist/core/src/columns/helper.d.ts +196 -0
- package/dist/core/src/columns/helper.js +250 -0
- package/dist/core/src/columns/labels.d.ts +12 -0
- package/dist/core/src/columns/labels.js +21 -0
- package/dist/core/src/contract.d.ts +12 -0
- package/dist/core/src/contract.js +12 -0
- package/dist/core/src/contracts/aiColumnMeta.d.ts +102 -0
- package/dist/core/src/contracts/aiColumnMeta.js +197 -0
- package/dist/core/src/contracts/describeColumns.d.ts +48 -0
- package/dist/core/src/contracts/describeColumns.js +40 -0
- package/dist/core/src/contracts/exportData.d.ts +150 -0
- package/dist/core/src/contracts/exportData.js +228 -0
- package/dist/core/src/contracts/index.d.ts +6 -0
- package/dist/core/src/contracts/index.js +12 -0
- package/dist/core/src/contracts/legacyUrl.d.ts +47 -0
- package/dist/core/src/contracts/legacyUrl.js +156 -0
- package/dist/core/src/contracts/preset.d.ts +104 -0
- package/dist/core/src/contracts/preset.js +141 -0
- package/dist/core/src/contracts/presetStore.d.ts +81 -0
- package/dist/core/src/contracts/presetStore.js +146 -0
- package/dist/core/src/contracts/usePresets.d.ts +51 -0
- package/dist/core/src/contracts/usePresets.js +170 -0
- package/dist/core/src/defaults.d.ts +7 -0
- package/dist/core/src/defaults.js +9 -0
- package/dist/core/src/filters/codec.d.ts +20 -0
- package/dist/core/src/filters/codec.js +61 -0
- package/dist/core/src/filters/date.d.ts +30 -0
- package/dist/core/src/filters/date.js +179 -0
- package/dist/core/src/filters/describe.d.ts +45 -0
- package/dist/core/src/filters/describe.js +189 -0
- package/dist/core/src/filters/kind.d.ts +52 -0
- package/dist/core/src/filters/kind.js +110 -0
- package/dist/core/src/filters/kinds/_list.d.ts +13 -0
- package/dist/core/src/filters/kinds/_list.js +42 -0
- package/dist/core/src/filters/kinds/boolean.d.ts +3 -0
- package/dist/core/src/filters/kinds/boolean.js +21 -0
- package/dist/core/src/filters/kinds/cascade.d.ts +6 -0
- package/dist/core/src/filters/kinds/cascade.js +16 -0
- package/dist/core/src/filters/kinds/commonCode.d.ts +3 -0
- package/dist/core/src/filters/kinds/commonCode.js +13 -0
- package/dist/core/src/filters/kinds/date.d.ts +9 -0
- package/dist/core/src/filters/kinds/date.js +32 -0
- package/dist/core/src/filters/kinds/enum.d.ts +4 -0
- package/dist/core/src/filters/kinds/enum.js +13 -0
- package/dist/core/src/filters/kinds/index.d.ts +49 -0
- package/dist/core/src/filters/kinds/index.js +39 -0
- package/dist/core/src/filters/kinds/linear.d.ts +5 -0
- package/dist/core/src/filters/kinds/linear.js +16 -0
- package/dist/core/src/filters/kinds/number.d.ts +5 -0
- package/dist/core/src/filters/kinds/number.js +19 -0
- package/dist/core/src/filters/kinds/period.d.ts +4 -0
- package/dist/core/src/filters/kinds/period.js +17 -0
- package/dist/core/src/filters/kinds/relation.d.ts +5 -0
- package/dist/core/src/filters/kinds/relation.js +17 -0
- package/dist/core/src/filters/kinds/schedule.d.ts +5 -0
- package/dist/core/src/filters/kinds/schedule.js +16 -0
- package/dist/core/src/filters/kinds/text.d.ts +4 -0
- package/dist/core/src/filters/kinds/text.js +18 -0
- package/dist/core/src/filters/linear.d.ts +33 -0
- package/dist/core/src/filters/linear.js +65 -0
- package/dist/core/src/filters/list.d.ts +32 -0
- package/dist/core/src/filters/list.js +87 -0
- package/dist/core/src/filters/number.d.ts +17 -0
- package/dist/core/src/filters/number.js +82 -0
- package/dist/core/src/filters/registry.d.ts +23 -0
- package/dist/core/src/filters/registry.js +121 -0
- package/dist/core/src/filters/relation.d.ts +26 -0
- package/dist/core/src/filters/relation.js +75 -0
- package/dist/core/src/filters/schedule.d.ts +21 -0
- package/dist/core/src/filters/schedule.js +60 -0
- package/dist/core/src/filters/text.d.ts +44 -0
- package/dist/core/src/filters/text.js +127 -0
- package/dist/core/src/hooks/index.d.ts +6 -0
- package/dist/core/src/hooks/index.js +6 -0
- package/dist/core/src/hooks/useDataTable.d.ts +13 -0
- package/dist/core/src/hooks/useDataTable.js +109 -0
- package/dist/core/src/hooks/useGrouping.d.ts +33 -0
- package/dist/core/src/hooks/useGrouping.js +57 -0
- package/dist/core/src/hooks/useSearchHistory.d.ts +20 -0
- package/dist/core/src/hooks/useSearchHistory.js +52 -0
- package/dist/core/src/hooks/useTableData.d.ts +25 -0
- package/dist/core/src/hooks/useTableData.js +49 -0
- package/dist/core/src/hooks/useTableFromDefinition.d.ts +47 -0
- package/dist/core/src/hooks/useTableFromDefinition.js +174 -0
- package/dist/core/src/hooks/useTableSearch.d.ts +10 -0
- package/dist/core/src/hooks/useTableSearch.js +434 -0
- package/dist/core/src/hooks/useUrlSync.d.ts +64 -0
- package/dist/core/src/hooks/useUrlSync.js +145 -0
- package/dist/core/src/index.d.ts +38 -0
- package/dist/core/src/index.js +43 -0
- package/dist/core/src/pivot/runClientPivot.d.ts +101 -0
- package/dist/core/src/pivot/runClientPivot.js +230 -0
- package/dist/core/src/pivot/transforms.d.ts +19 -0
- package/dist/core/src/pivot/transforms.js +105 -0
- package/dist/core/src/registry.d.ts +46 -0
- package/dist/core/src/registry.js +67 -0
- package/dist/core/src/types/column.d.ts +213 -0
- package/dist/core/src/types/column.js +1 -0
- package/dist/core/src/types/data.d.ts +63 -0
- package/dist/core/src/types/data.js +1 -0
- package/dist/core/src/types/definition.d.ts +239 -0
- package/dist/core/src/types/definition.js +59 -0
- package/dist/core/src/types/filter.d.ts +94 -0
- package/dist/core/src/types/filter.js +1 -0
- package/dist/core/src/types/filterKind.d.ts +204 -0
- package/dist/core/src/types/filterKind.js +1 -0
- package/dist/core/src/types/grouping.d.ts +80 -0
- package/dist/core/src/types/grouping.js +4 -0
- package/dist/core/src/types/index.d.ts +8 -0
- package/dist/core/src/types/index.js +8 -0
- package/dist/core/src/types/operators.d.ts +64 -0
- package/dist/core/src/types/operators.js +108 -0
- package/dist/core/src/types/request.d.ts +114 -0
- package/dist/core/src/types/request.js +1 -0
- package/dist/core/src/types/search.d.ts +112 -0
- package/dist/core/src/types/search.js +1 -0
- package/dist/core/src/types/sources.d.ts +84 -0
- package/dist/core/src/types/sources.js +1 -0
- package/dist/core/src/types/table.d.ts +40 -0
- package/dist/core/src/types/table.js +1 -0
- package/dist/core/src/utils/compare.d.ts +13 -0
- package/dist/core/src/utils/compare.js +53 -0
- package/dist/core/src/utils/dateText.d.ts +12 -0
- package/dist/core/src/utils/dateText.js +30 -0
- package/dist/core/src/utils/getValue.d.ts +9 -0
- package/dist/core/src/utils/getValue.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/mui/src/TableLayout.d.ts +75 -0
- package/dist/mui/src/TableLayout.js +162 -0
- package/dist/mui/src/TableScreen.d.ts +70 -0
- package/dist/mui/src/TableScreen.js +174 -0
- package/dist/mui/src/addons/FilterModal.d.ts +45 -0
- package/dist/mui/src/addons/FilterModal.js +39 -0
- package/dist/mui/src/addons/FilterStatusModal.d.ts +40 -0
- package/dist/mui/src/addons/FilterStatusModal.js +68 -0
- package/dist/mui/src/addons/PresetBar.d.ts +56 -0
- package/dist/mui/src/addons/PresetBar.js +145 -0
- package/dist/mui/src/addons/ShareDropdown.d.ts +35 -0
- package/dist/mui/src/addons/ShareDropdown.js +41 -0
- package/dist/mui/src/addons/searchStore.d.ts +44 -0
- package/dist/mui/src/addons/searchStore.js +112 -0
- package/dist/mui/src/addons/searchSummary.d.ts +106 -0
- package/dist/mui/src/addons/searchSummary.js +109 -0
- package/dist/mui/src/columns/displayColumns.d.ts +100 -0
- package/dist/mui/src/columns/displayColumns.js +174 -0
- package/dist/mui/src/content/CardContent.d.ts +24 -0
- package/dist/mui/src/content/CardContent.js +25 -0
- package/dist/mui/src/content/ColumnMenu.d.ts +43 -0
- package/dist/mui/src/content/ColumnMenu.js +40 -0
- package/dist/mui/src/content/ColumnVisibilityMenu.d.ts +14 -0
- package/dist/mui/src/content/ColumnVisibilityMenu.js +37 -0
- package/dist/mui/src/content/EditCell.d.ts +19 -0
- package/dist/mui/src/content/EditCell.js +61 -0
- package/dist/mui/src/content/HeaderCell.d.ts +59 -0
- package/dist/mui/src/content/HeaderCell.js +88 -0
- package/dist/mui/src/content/SelectionActionBar.d.ts +17 -0
- package/dist/mui/src/content/SelectionActionBar.js +19 -0
- package/dist/mui/src/content/SortControl.d.ts +17 -0
- package/dist/mui/src/content/SortControl.js +45 -0
- package/dist/mui/src/content/TableContent.d.ts +191 -0
- package/dist/mui/src/content/TableContent.js +525 -0
- package/dist/mui/src/content/TableErrorFallback.d.ts +24 -0
- package/dist/mui/src/content/TableErrorFallback.js +29 -0
- package/dist/mui/src/content/cellError.d.ts +13 -0
- package/dist/mui/src/content/cellError.js +20 -0
- package/dist/mui/src/content/cellRenderers.d.ts +71 -0
- package/dist/mui/src/content/cellRenderers.js +127 -0
- package/dist/mui/src/content/cellValue.d.ts +27 -0
- package/dist/mui/src/content/cellValue.js +82 -0
- package/dist/mui/src/content/columnOrder.d.ts +7 -0
- package/dist/mui/src/content/columnOrder.js +48 -0
- package/dist/mui/src/content/density.d.ts +13 -0
- package/dist/mui/src/content/density.js +12 -0
- package/dist/mui/src/content/menuItems.d.ts +118 -0
- package/dist/mui/src/content/menuItems.js +128 -0
- package/dist/mui/src/content/pinning.d.ts +17 -0
- package/dist/mui/src/content/pinning.js +65 -0
- package/dist/mui/src/exporters/index.d.ts +1 -0
- package/dist/mui/src/exporters/index.js +1 -0
- package/dist/mui/src/exporters/useExportFormatters.d.ts +13 -0
- package/dist/mui/src/exporters/useExportFormatters.js +17 -0
- package/dist/mui/src/filter/FilterArea.d.ts +40 -0
- package/dist/mui/src/filter/FilterArea.js +37 -0
- package/dist/mui/src/filter/FilterInput.d.ts +14 -0
- package/dist/mui/src/filter/FilterInput.js +18 -0
- package/dist/mui/src/filter/inputs/DateFilterInput.d.ts +14 -0
- package/dist/mui/src/filter/inputs/DateFilterInput.js +112 -0
- package/dist/mui/src/filter/inputs/LinearFilterInput.d.ts +10 -0
- package/dist/mui/src/filter/inputs/LinearFilterInput.js +35 -0
- package/dist/mui/src/filter/inputs/NumberFilterInput.d.ts +12 -0
- package/dist/mui/src/filter/inputs/NumberFilterInput.js +111 -0
- package/dist/mui/src/filter/inputs/OperatorMenu.d.ts +14 -0
- package/dist/mui/src/filter/inputs/OperatorMenu.js +25 -0
- package/dist/mui/src/filter/inputs/RelationFilterInput.d.ts +11 -0
- package/dist/mui/src/filter/inputs/RelationFilterInput.js +67 -0
- package/dist/mui/src/filter/inputs/ScheduleFilterInput.d.ts +8 -0
- package/dist/mui/src/filter/inputs/ScheduleFilterInput.js +17 -0
- package/dist/mui/src/filter/inputs/SelectFilterInput.d.ts +16 -0
- package/dist/mui/src/filter/inputs/SelectFilterInput.js +57 -0
- package/dist/mui/src/filter/inputs/TextFilterInput.d.ts +13 -0
- package/dist/mui/src/filter/inputs/TextFilterInput.js +55 -0
- package/dist/mui/src/filter/kinds.d.ts +58 -0
- package/dist/mui/src/filter/kinds.js +103 -0
- package/dist/mui/src/grouping/AggregateChip.d.ts +55 -0
- package/dist/mui/src/grouping/AggregateChip.js +128 -0
- package/dist/mui/src/grouping/AggregationChips.d.ts +49 -0
- package/dist/mui/src/grouping/AggregationChips.js +80 -0
- package/dist/mui/src/grouping/AggregationSettingsButton.d.ts +14 -0
- package/dist/mui/src/grouping/AggregationSettingsButton.js +19 -0
- package/dist/mui/src/grouping/GroupingBar.d.ts +59 -0
- package/dist/mui/src/grouping/GroupingBar.js +267 -0
- package/dist/mui/src/grouping/StepExpandButton.d.ts +12 -0
- package/dist/mui/src/grouping/StepExpandButton.js +18 -0
- package/dist/mui/src/grouping/groupRow.d.ts +68 -0
- package/dist/mui/src/grouping/groupRow.js +129 -0
- package/dist/mui/src/headers/defaultHeader.d.ts +28 -0
- package/dist/mui/src/headers/defaultHeader.js +90 -0
- package/dist/mui/src/headers/defineHeader.d.ts +21 -0
- package/dist/mui/src/headers/defineHeader.js +68 -0
- package/dist/mui/src/headers/index.d.ts +5 -0
- package/dist/mui/src/headers/index.js +3 -0
- package/dist/mui/src/headers/meta.d.ts +18 -0
- package/dist/mui/src/headers/meta.js +11 -0
- package/dist/mui/src/headers/types.d.ts +84 -0
- package/dist/mui/src/headers/types.js +1 -0
- package/dist/mui/src/index.d.ts +110 -0
- package/dist/mui/src/index.js +79 -0
- package/dist/mui/src/layout/ResizeHandle.d.ts +30 -0
- package/dist/mui/src/layout/ResizeHandle.js +66 -0
- package/dist/mui/src/menus/columnMenu.d.ts +45 -0
- package/dist/mui/src/menus/columnMenu.js +144 -0
- package/dist/mui/src/menus/copy.d.ts +5 -0
- package/dist/mui/src/menus/copy.js +25 -0
- package/dist/mui/src/menus/index.d.ts +6 -0
- package/dist/mui/src/menus/index.js +4 -0
- package/dist/mui/src/menus/resolve.d.ts +15 -0
- package/dist/mui/src/menus/resolve.js +60 -0
- package/dist/mui/src/menus/rowMenu.d.ts +46 -0
- package/dist/mui/src/menus/rowMenu.js +76 -0
- package/dist/mui/src/pagination/PaginationArea.d.ts +16 -0
- package/dist/mui/src/pagination/PaginationArea.js +23 -0
- package/dist/mui/src/pivot/PivotTable.d.ts +18 -0
- package/dist/mui/src/pivot/PivotTable.js +100 -0
- package/dist/mui/src/provider/KokoTableProvider.d.ts +184 -0
- package/dist/mui/src/provider/KokoTableProvider.js +118 -0
- package/dist/mui/src/provider/labels.d.ts +85 -0
- package/dist/mui/src/provider/labels.js +104 -0
- package/dist/mui/src/registry.d.ts +30 -0
- package/dist/mui/src/registry.js +5 -0
- package/dist/mui/src/toolbar/GlobalFilterField.d.ts +36 -0
- package/dist/mui/src/toolbar/GlobalFilterField.js +78 -0
- package/dist/mui/src/virtual/VirtualTableContent.d.ts +41 -0
- package/dist/mui/src/virtual/VirtualTableContent.js +58 -0
- package/dist/mui/src/virtual/VirtualTableScreen.d.ts +13 -0
- package/dist/mui/src/virtual/VirtualTableScreen.js +23 -0
- package/dist/mui/src/virtual.d.ts +4 -0
- package/dist/mui/src/virtual.js +4 -0
- package/dist/mui/src/virtualColumns/VirtualColumnCard.d.ts +26 -0
- package/dist/mui/src/virtualColumns/VirtualColumnCard.js +29 -0
- package/dist/mui/src/virtualColumns/VirtualColumnDialog.d.ts +27 -0
- package/dist/mui/src/virtualColumns/VirtualColumnDialog.js +154 -0
- package/dist/mui/src/virtualColumns/VirtualColumnSection.d.ts +31 -0
- package/dist/mui/src/virtualColumns/VirtualColumnSection.js +55 -0
- package/dist/mui/src/virtualColumns/expression.d.ts +68 -0
- package/dist/mui/src/virtualColumns/expression.js +489 -0
- package/dist/mui/src/virtualColumns/useVirtualColumns.d.ts +67 -0
- package/dist/mui/src/virtualColumns/useVirtualColumns.js +111 -0
- package/dist/mui/src/virtualColumns/virtualColumnDefs.d.ts +29 -0
- package/dist/mui/src/virtualColumns/virtualColumnDefs.js +51 -0
- package/dist/mui-virtual.d.ts +1 -0
- package/dist/mui-virtual.js +2 -0
- package/dist/mui.d.ts +1 -0
- package/dist/mui.js +2 -0
- package/dist/query/src/index.d.ts +6 -0
- package/dist/query/src/index.js +7 -0
- package/dist/query/src/serverPivot.d.ts +42 -0
- package/dist/query/src/serverPivot.js +153 -0
- package/dist/query/src/useServerGrouping.d.ts +52 -0
- package/dist/query/src/useServerGrouping.js +186 -0
- package/dist/query/src/useServerTable.d.ts +20 -0
- package/dist/query/src/useServerTable.js +106 -0
- package/dist/query/src/useServerTableData.d.ts +48 -0
- package/dist/query/src/useServerTableData.js +64 -0
- package/dist/query/src/wire.d.ts +16 -0
- package/dist/query/src/wire.js +32 -0
- package/dist/query.d.ts +1 -0
- package/dist/query.js +2 -0
- package/package.json +125 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Grid, Typography } from "@mui/material";
|
|
4
|
+
import AddIcon from "@mui/icons-material/Add";
|
|
5
|
+
import { VirtualColumnCard } from "./VirtualColumnCard";
|
|
6
|
+
import { VirtualColumnDialog } from "./VirtualColumnDialog";
|
|
7
|
+
/**
|
|
8
|
+
* 가상 열 섹션 — 카드 목록 + 추가 버튼 + 추가/편집 다이얼로그.
|
|
9
|
+
*
|
|
10
|
+
* 기존 ERP `VirtualColumnSection` 대응. `FilterArea` 의 children 이나 툴바 어디든 놓을 수 있고
|
|
11
|
+
* 다이얼로그 상태를 스스로 관리한다. 상태는 훅(`useVirtualColumns`)이 검색 상태 위에서 들고 있다.
|
|
12
|
+
*/
|
|
13
|
+
export function VirtualColumnSection({ virtualColumns, fields: fieldsProp, gridSize, enableFilter = false, addLabel = "가상 열 추가", cardWidth = 220, }) {
|
|
14
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
15
|
+
const [editing, setEditing] = useState(undefined);
|
|
16
|
+
const fields = fieldsProp ?? virtualColumns.fields;
|
|
17
|
+
const { search } = virtualColumns;
|
|
18
|
+
const filterOf = (column) => {
|
|
19
|
+
if (!enableFilter)
|
|
20
|
+
return undefined;
|
|
21
|
+
const entry = search.draft.columnFilters.find((f) => f.id === column.slotKey);
|
|
22
|
+
const fallback = column.type === "number" ? "greaterThanOrEqualTo" : "contains";
|
|
23
|
+
const operator = (search.draft.columnFilterFns[column.slotKey] ?? fallback);
|
|
24
|
+
return {
|
|
25
|
+
value: entry?.value,
|
|
26
|
+
operator,
|
|
27
|
+
onChange: (value, nextOperator) => {
|
|
28
|
+
if (nextOperator !== operator)
|
|
29
|
+
search.setOperator(column.slotKey, nextOperator);
|
|
30
|
+
search.setFilter(column.slotKey, value);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const openAdd = () => { setEditing(undefined); setDialogOpen(true); };
|
|
35
|
+
const openEdit = (column) => { setEditing(column); setDialogOpen(true); };
|
|
36
|
+
const close = () => { setDialogOpen(false); setEditing(undefined); };
|
|
37
|
+
const cards = virtualColumns.columns.map((column) => (_jsx(VirtualColumnCard, { column: column, fields: fields, onEdit: () => openEdit(column), onRemove: () => virtualColumns.remove(column.slotKey), filter: filterOf(column) }, column.slotKey)));
|
|
38
|
+
const addButton = !virtualColumns.isMaxReached && (_jsxs(Box, { role: "button", tabIndex: 0, "aria-label": typeof addLabel === "string" ? addLabel : "가상 열 추가", onClick: openAdd, onKeyDown: (event) => {
|
|
39
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
openAdd();
|
|
42
|
+
}
|
|
43
|
+
}, sx: {
|
|
44
|
+
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
|
45
|
+
border: "2px dashed", borderColor: "divider", borderRadius: 1, p: 1, minHeight: 64,
|
|
46
|
+
cursor: "pointer", color: "text.secondary",
|
|
47
|
+
"&:hover": { borderColor: "info.main", color: "info.main" },
|
|
48
|
+
transition: "all 0.2s",
|
|
49
|
+
}, children: [_jsx(AddIcon, { sx: { fontSize: 22 } }), _jsx(Typography, { variant: "caption", sx: { fontWeight: 500, mt: 0.25 }, children: addLabel })] }));
|
|
50
|
+
const dialog = (_jsx(VirtualColumnDialog, { open: dialogOpen, onClose: close, initialValues: editing, fields: fields, validate: virtualColumns.validate, onSubmit: (column) => (editing ? virtualColumns.update(editing.slotKey, column) : virtualColumns.add(column)) }));
|
|
51
|
+
if (gridSize) {
|
|
52
|
+
return (_jsxs(_Fragment, { children: [virtualColumns.columns.map((column, index) => (_jsx(Grid, { size: gridSize, children: cards[index] }, column.slotKey))), addButton && _jsx(Grid, { size: gridSize, children: addButton }), dialog] }));
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs(Box, { "data-testid": "virtual-column-section", sx: { display: "flex", flexWrap: "wrap", gap: 1, alignItems: "stretch" }, children: [cards.map((card, index) => (_jsx(Box, { sx: { width: cardWidth, minWidth: 0 }, children: card }, virtualColumns.columns[index].slotKey))), addButton && _jsx(Box, { sx: { width: cardWidth }, children: addButton }), dialog] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 가상 컬럼 수식 — 클라이언트 검증·평가.
|
|
3
|
+
*
|
|
4
|
+
* 서버 `QueryBuilder/core/expression_parser.py` 의 문법을 그대로 옮겼다. 서버가 400 을
|
|
5
|
+
* 줄 식은 **여기서 먼저 거른다** — 다이얼로그가 저장을 막고 이유를 보여 줄 수 있어야
|
|
6
|
+
* 하고, 스토리·테스트에서 서버 없이 같은 규칙으로 값을 계산할 수 있어야 한다.
|
|
7
|
+
*
|
|
8
|
+
* 1. 산술 수식 (`number` / `boolean`): 식별자 · 숫자 · `+ - * /` · 괄호 ·
|
|
9
|
+
* 함수 `DATE_TRUNC('month', col)` / `EXTRACT('year', col)`.
|
|
10
|
+
* 컬럼 참조는 `{column}` 으로 통일한다 (서버가 `{x}` → `x` 로 정규화한다).
|
|
11
|
+
* 2. 문자열 템플릿 (`string`): `"{product} / {option}"` — `{field}` 참조가 하나 이상 있어야 한다.
|
|
12
|
+
*/
|
|
13
|
+
export type VirtualColumnType = "number" | "string" | "boolean";
|
|
14
|
+
export interface VirtualColumnField {
|
|
15
|
+
/** 컬럼 id — 서버 필드명 */
|
|
16
|
+
key: string;
|
|
17
|
+
/** 사용자에게 보이는 이름 */
|
|
18
|
+
header: string;
|
|
19
|
+
}
|
|
20
|
+
export type ExpressionValidation = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
export type TokenType = "identifier" | "number" | "operator" | "lparen" | "rparen" | "function" | "string_literal" | "comma";
|
|
27
|
+
export interface Token {
|
|
28
|
+
type: TokenType;
|
|
29
|
+
value: string;
|
|
30
|
+
}
|
|
31
|
+
/** `{field}` / `{relation.field}` 참조 */
|
|
32
|
+
export declare const TEMPLATE_FIELD_PATTERN: RegExp;
|
|
33
|
+
/** 산술 수식에서 `{field}` → `field` (서버 `_normalize_expr`) */
|
|
34
|
+
export declare function normalizeExpression(expr: string): string;
|
|
35
|
+
/** 수식·템플릿이 참조하는 필드 목록 (중복 제거, 등장 순) */
|
|
36
|
+
export declare function referencedFields(expr: string, type?: VirtualColumnType): string[];
|
|
37
|
+
/** 수식을 토큰으로 나눈다. 인식할 수 없는 문자가 있으면 throw */
|
|
38
|
+
export declare function tokenizeExpression(expr: string): Token[];
|
|
39
|
+
/**
|
|
40
|
+
* 수식 검증. `fields` 를 주면 식별자를 그 목록으로 제한한다(서버의 모델 컬럼 검사와 같은 역할).
|
|
41
|
+
* 주지 않으면 문법만 본다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function validateExpression(expr: string, options?: {
|
|
44
|
+
type?: VirtualColumnType;
|
|
45
|
+
fields?: ReadonlyArray<VirtualColumnField | string>;
|
|
46
|
+
}): ExpressionValidation;
|
|
47
|
+
/**
|
|
48
|
+
* 산술 수식을 행 값으로 평가한다. 0 으로 나누면 `null`.
|
|
49
|
+
* 검증을 통과한 식만 넣는다 — 문법 오류는 throw 한다.
|
|
50
|
+
*/
|
|
51
|
+
export declare function evaluateExpression(row: unknown, expr: string): unknown;
|
|
52
|
+
/** 문자열 템플릿 평가 — `{field}` 를 행 값으로 치환한다. NULL 은 빈 문자열 */
|
|
53
|
+
export declare function evaluateTemplate(row: unknown, template: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* 타입에 맞춰 평가한다 — `string` 은 템플릿, 나머지는 산술.
|
|
56
|
+
* `boolean` 은 0/NULL 을 false 로 강제한다 (서버 `fill_virtual_columns` 와 같다).
|
|
57
|
+
*/
|
|
58
|
+
export declare function evaluateVirtualColumn(row: unknown, spec: {
|
|
59
|
+
expr: string;
|
|
60
|
+
type?: VirtualColumnType;
|
|
61
|
+
}): unknown;
|
|
62
|
+
/** 응답 행마다 가상 컬럼 값을 얹는다 — 스토리의 로컬 서버가 쓴다 */
|
|
63
|
+
export declare function applyVirtualColumns<T>(rows: T[], spec: Record<string, {
|
|
64
|
+
expr: string;
|
|
65
|
+
type?: VirtualColumnType;
|
|
66
|
+
}> | undefined): T[];
|
|
67
|
+
/** 수식의 `{key}` / `key` 를 컬럼 헤더로 바꿔 사람이 읽을 형태로 만든다 (카드·미리보기용) */
|
|
68
|
+
export declare function describeExpression(expr: string, fields: ReadonlyArray<VirtualColumnField>): string;
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 가상 컬럼 수식 — 클라이언트 검증·평가.
|
|
3
|
+
*
|
|
4
|
+
* 서버 `QueryBuilder/core/expression_parser.py` 의 문법을 그대로 옮겼다. 서버가 400 을
|
|
5
|
+
* 줄 식은 **여기서 먼저 거른다** — 다이얼로그가 저장을 막고 이유를 보여 줄 수 있어야
|
|
6
|
+
* 하고, 스토리·테스트에서 서버 없이 같은 규칙으로 값을 계산할 수 있어야 한다.
|
|
7
|
+
*
|
|
8
|
+
* 1. 산술 수식 (`number` / `boolean`): 식별자 · 숫자 · `+ - * /` · 괄호 ·
|
|
9
|
+
* 함수 `DATE_TRUNC('month', col)` / `EXTRACT('year', col)`.
|
|
10
|
+
* 컬럼 참조는 `{column}` 으로 통일한다 (서버가 `{x}` → `x` 로 정규화한다).
|
|
11
|
+
* 2. 문자열 템플릿 (`string`): `"{product} / {option}"` — `{field}` 참조가 하나 이상 있어야 한다.
|
|
12
|
+
*/
|
|
13
|
+
const OPERATOR_PRECEDENCE = { "+": 1, "-": 1, "*": 2, "/": 2 };
|
|
14
|
+
const ALLOWED_FUNCTIONS = {
|
|
15
|
+
DATE_TRUNC: { args: 2 },
|
|
16
|
+
EXTRACT: { args: 2 },
|
|
17
|
+
};
|
|
18
|
+
const VALID_DATE_PRECISIONS = new Set(["year", "quarter", "month", "week", "day", "hour", "minute", "second"]);
|
|
19
|
+
const VALID_EXTRACT_FIELDS = new Set([
|
|
20
|
+
"year", "quarter", "month", "week", "day", "hour", "minute", "second", "dow", "doy", "epoch",
|
|
21
|
+
]);
|
|
22
|
+
/** 서버 `TOKEN_PATTERN` 과 같은 순서: 문자열 리터럴 > 숫자 > 식별자 > 연산자 > 쉼표 > 괄호 > 공백 */
|
|
23
|
+
const TOKEN_PATTERN = /('(?:[^']*)')|(\d+\.?\d*)|([a-zA-Z_]\w*(?:\.\w+)*)|([+\-*/])|(,)|(\()|(\))|(\s+)/y;
|
|
24
|
+
/** `{field}` / `{relation.field}` 참조 */
|
|
25
|
+
export const TEMPLATE_FIELD_PATTERN = /\{([\w.]+)\}/g;
|
|
26
|
+
/** 산술 수식에서 `{field}` → `field` (서버 `_normalize_expr`) */
|
|
27
|
+
export function normalizeExpression(expr) {
|
|
28
|
+
return expr.replace(TEMPLATE_FIELD_PATTERN, "$1");
|
|
29
|
+
}
|
|
30
|
+
/** 수식·템플릿이 참조하는 필드 목록 (중복 제거, 등장 순) */
|
|
31
|
+
export function referencedFields(expr, type = "number") {
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
if (type === "string") {
|
|
34
|
+
for (const match of expr.matchAll(TEMPLATE_FIELD_PATTERN))
|
|
35
|
+
seen.add(match[1]);
|
|
36
|
+
return [...seen];
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
for (const token of tokenizeExpression(normalizeExpression(expr))) {
|
|
40
|
+
if (token.type === "identifier")
|
|
41
|
+
seen.add(token.value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// 토큰화가 안 되는 식이면 참조도 없다 — 검증이 따로 이유를 만든다
|
|
46
|
+
}
|
|
47
|
+
return [...seen];
|
|
48
|
+
}
|
|
49
|
+
/** 수식을 토큰으로 나눈다. 인식할 수 없는 문자가 있으면 throw */
|
|
50
|
+
export function tokenizeExpression(expr) {
|
|
51
|
+
const tokens = [];
|
|
52
|
+
let pos = 0;
|
|
53
|
+
while (pos < expr.length) {
|
|
54
|
+
TOKEN_PATTERN.lastIndex = pos;
|
|
55
|
+
const match = TOKEN_PATTERN.exec(expr);
|
|
56
|
+
if (!match || match.index !== pos) {
|
|
57
|
+
throw new Error(`수식에서 인식할 수 없는 문자: '${expr[pos]}' (위치 ${pos})`);
|
|
58
|
+
}
|
|
59
|
+
if (match[1] !== undefined) {
|
|
60
|
+
tokens.push({ type: "string_literal", value: match[1].slice(1, -1) });
|
|
61
|
+
}
|
|
62
|
+
else if (match[2] !== undefined) {
|
|
63
|
+
tokens.push({ type: "number", value: match[2] });
|
|
64
|
+
}
|
|
65
|
+
else if (match[3] !== undefined) {
|
|
66
|
+
const name = match[3];
|
|
67
|
+
const upper = name.toUpperCase();
|
|
68
|
+
if (upper in ALLOWED_FUNCTIONS && expr.slice(pos + name.length).trimStart().startsWith("(")) {
|
|
69
|
+
tokens.push({ type: "function", value: upper });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
tokens.push({ type: "identifier", value: name });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (match[4] !== undefined) {
|
|
76
|
+
tokens.push({ type: "operator", value: match[4] });
|
|
77
|
+
}
|
|
78
|
+
else if (match[5] !== undefined) {
|
|
79
|
+
tokens.push({ type: "comma", value: "," });
|
|
80
|
+
}
|
|
81
|
+
else if (match[6] !== undefined) {
|
|
82
|
+
tokens.push({ type: "lparen", value: "(" });
|
|
83
|
+
}
|
|
84
|
+
else if (match[7] !== undefined) {
|
|
85
|
+
tokens.push({ type: "rparen", value: ")" });
|
|
86
|
+
}
|
|
87
|
+
pos += match[0].length;
|
|
88
|
+
}
|
|
89
|
+
if (tokens.length === 0)
|
|
90
|
+
throw new Error("빈 수식입니다.");
|
|
91
|
+
return tokens;
|
|
92
|
+
}
|
|
93
|
+
/** 밑줄로 시작하는 세그먼트는 서버가 무조건 거부한다 (`__class__` 류 내부 경로 차단) */
|
|
94
|
+
function isSafeIdentifier(name) {
|
|
95
|
+
return name.split(".").every((part) => part.length > 0 && !part.startsWith("_"));
|
|
96
|
+
}
|
|
97
|
+
function validateTokens(tokens, allowed) {
|
|
98
|
+
let depth = 0;
|
|
99
|
+
const funcStack = [];
|
|
100
|
+
const funcArgCount = [];
|
|
101
|
+
tokens.forEach((token, i) => {
|
|
102
|
+
const prev = tokens[i - 1];
|
|
103
|
+
switch (token.type) {
|
|
104
|
+
case "identifier": {
|
|
105
|
+
const known = allowed === null ? true : allowed.has(token.value);
|
|
106
|
+
if (!isSafeIdentifier(token.value) || !known) {
|
|
107
|
+
const hint = allowed ? ` 사용 가능한 컬럼: ${[...allowed].sort().join(", ")}` : "";
|
|
108
|
+
throw new Error(`허용되지 않는 식별자: '${token.value}'.${hint}`);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "function":
|
|
113
|
+
if (!(token.value in ALLOWED_FUNCTIONS))
|
|
114
|
+
throw new Error(`허용되지 않는 함수: '${token.value}'`);
|
|
115
|
+
break;
|
|
116
|
+
case "string_literal": {
|
|
117
|
+
const fn = funcStack[funcStack.length - 1];
|
|
118
|
+
if (!fn)
|
|
119
|
+
throw new Error(`문자열 리터럴 '${token.value}'은(는) 함수 인자로만 사용할 수 있습니다.`);
|
|
120
|
+
const value = token.value.toLowerCase();
|
|
121
|
+
if (fn === "DATE_TRUNC" && !VALID_DATE_PRECISIONS.has(value)) {
|
|
122
|
+
throw new Error(`DATE_TRUNC의 유효하지 않은 precision: '${token.value}'. 허용값: ${[...VALID_DATE_PRECISIONS].sort().join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
if (fn === "EXTRACT" && !VALID_EXTRACT_FIELDS.has(value)) {
|
|
125
|
+
throw new Error(`EXTRACT의 유효하지 않은 field: '${token.value}'. 허용값: ${[...VALID_EXTRACT_FIELDS].sort().join(", ")}`);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "operator":
|
|
130
|
+
if (!(token.value in OPERATOR_PRECEDENCE))
|
|
131
|
+
throw new Error(`허용되지 않는 연산자: '${token.value}'`);
|
|
132
|
+
break;
|
|
133
|
+
case "lparen":
|
|
134
|
+
depth += 1;
|
|
135
|
+
if (prev?.type === "function") {
|
|
136
|
+
funcStack.push(prev.value);
|
|
137
|
+
funcArgCount.push(1);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
funcStack.push("");
|
|
141
|
+
funcArgCount.push(0);
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "rparen": {
|
|
145
|
+
depth -= 1;
|
|
146
|
+
if (depth < 0)
|
|
147
|
+
throw new Error("괄호 짝이 맞지 않습니다: 닫는 괄호 초과");
|
|
148
|
+
const fn = funcStack.pop();
|
|
149
|
+
const count = funcArgCount.pop() ?? 0;
|
|
150
|
+
if (fn) {
|
|
151
|
+
const expected = ALLOWED_FUNCTIONS[fn].args;
|
|
152
|
+
if (count !== expected)
|
|
153
|
+
throw new Error(`${fn} 는 인자 ${expected}개가 필요합니다.`);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "comma": {
|
|
158
|
+
const fn = funcStack[funcStack.length - 1];
|
|
159
|
+
if (!fn)
|
|
160
|
+
throw new Error("쉼표 위치가 올바르지 않습니다.");
|
|
161
|
+
funcArgCount[funcArgCount.length - 1] = (funcArgCount[funcArgCount.length - 1] ?? 0) + 1;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (depth > 0)
|
|
169
|
+
throw new Error("괄호 짝이 맞지 않습니다: 여는 괄호 초과");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Shunting-yard → 후위 표기. 구조 오류(피연산자 부족 등)는 여기서 드러난다.
|
|
173
|
+
* 함수 호출은 `function` 토큰 하나로 접어 인자를 뒤에 둔다: `'month' col DATE_TRUNC`.
|
|
174
|
+
*/
|
|
175
|
+
function toPostfix(tokens) {
|
|
176
|
+
const output = [];
|
|
177
|
+
const stack = [];
|
|
178
|
+
for (const token of tokens) {
|
|
179
|
+
switch (token.type) {
|
|
180
|
+
case "number":
|
|
181
|
+
case "identifier":
|
|
182
|
+
case "string_literal":
|
|
183
|
+
output.push(token);
|
|
184
|
+
break;
|
|
185
|
+
case "function":
|
|
186
|
+
stack.push(token);
|
|
187
|
+
break;
|
|
188
|
+
case "comma":
|
|
189
|
+
while (stack.length > 0 && stack[stack.length - 1].type !== "lparen")
|
|
190
|
+
output.push(stack.pop());
|
|
191
|
+
if (stack.length === 0)
|
|
192
|
+
throw new Error("쉼표 위치가 올바르지 않습니다.");
|
|
193
|
+
break;
|
|
194
|
+
case "operator": {
|
|
195
|
+
const prec = OPERATOR_PRECEDENCE[token.value];
|
|
196
|
+
while (stack.length > 0) {
|
|
197
|
+
const top = stack[stack.length - 1];
|
|
198
|
+
if (top.type === "operator" && OPERATOR_PRECEDENCE[top.value] >= prec)
|
|
199
|
+
output.push(stack.pop());
|
|
200
|
+
else
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
stack.push(token);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "lparen":
|
|
207
|
+
stack.push(token);
|
|
208
|
+
break;
|
|
209
|
+
case "rparen": {
|
|
210
|
+
while (stack.length > 0 && stack[stack.length - 1].type !== "lparen")
|
|
211
|
+
output.push(stack.pop());
|
|
212
|
+
if (stack.length === 0)
|
|
213
|
+
throw new Error("괄호 짝이 맞지 않습니다.");
|
|
214
|
+
stack.pop();
|
|
215
|
+
if (stack.length > 0 && stack[stack.length - 1].type === "function")
|
|
216
|
+
output.push(stack.pop());
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
while (stack.length > 0) {
|
|
224
|
+
const top = stack.pop();
|
|
225
|
+
if (top.type === "lparen")
|
|
226
|
+
throw new Error("괄호 짝이 맞지 않습니다.");
|
|
227
|
+
output.push(top);
|
|
228
|
+
}
|
|
229
|
+
return output;
|
|
230
|
+
}
|
|
231
|
+
/** 후위식이 값 하나로 닫히는지 — 평가 없이 스택 깊이만 센다 */
|
|
232
|
+
function checkPostfix(postfix) {
|
|
233
|
+
let depth = 0;
|
|
234
|
+
for (const token of postfix) {
|
|
235
|
+
if (token.type === "operator") {
|
|
236
|
+
if (depth < 2)
|
|
237
|
+
throw new Error(`연산자 '${token.value}'에 대한 피연산자가 부족합니다.`);
|
|
238
|
+
depth -= 1;
|
|
239
|
+
}
|
|
240
|
+
else if (token.type === "function") {
|
|
241
|
+
const args = ALLOWED_FUNCTIONS[token.value].args;
|
|
242
|
+
if (depth < args)
|
|
243
|
+
throw new Error(`${token.value} 는 인자 ${args}개가 필요합니다.`);
|
|
244
|
+
depth -= args - 1;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
depth += 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (depth !== 1)
|
|
251
|
+
throw new Error("수식이 올바르지 않습니다: 최종 결과가 하나가 아닙니다.");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 수식 검증. `fields` 를 주면 식별자를 그 목록으로 제한한다(서버의 모델 컬럼 검사와 같은 역할).
|
|
255
|
+
* 주지 않으면 문법만 본다.
|
|
256
|
+
*/
|
|
257
|
+
export function validateExpression(expr, options = {}) {
|
|
258
|
+
const { type = "number", fields } = options;
|
|
259
|
+
const allowed = fields ? new Set(fields.map((f) => (typeof f === "string" ? f : f.key))) : null;
|
|
260
|
+
try {
|
|
261
|
+
if (type === "string") {
|
|
262
|
+
if (!expr.trim())
|
|
263
|
+
throw new Error("빈 수식입니다.");
|
|
264
|
+
const refs = referencedFields(expr, "string");
|
|
265
|
+
if (refs.length === 0)
|
|
266
|
+
throw new Error("템플릿에 {field} 참조가 없습니다.");
|
|
267
|
+
for (const field of refs) {
|
|
268
|
+
if (!isSafeIdentifier(field) || (allowed && !allowed.has(field))) {
|
|
269
|
+
const hint = allowed ? ` 사용 가능한 컬럼: ${[...allowed].sort().join(", ")}` : "";
|
|
270
|
+
throw new Error(`허용되지 않는 필드: '${field}'.${hint}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { ok: true };
|
|
274
|
+
}
|
|
275
|
+
const tokens = tokenizeExpression(normalizeExpression(expr));
|
|
276
|
+
validateTokens(tokens, allowed);
|
|
277
|
+
checkPostfix(toPostfix(tokens));
|
|
278
|
+
return { ok: true };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function readPath(row, path) {
|
|
285
|
+
let current = row;
|
|
286
|
+
for (const part of path.split(".")) {
|
|
287
|
+
if (current === null || current === undefined || typeof current !== "object")
|
|
288
|
+
return undefined;
|
|
289
|
+
current = current[part];
|
|
290
|
+
}
|
|
291
|
+
return current;
|
|
292
|
+
}
|
|
293
|
+
/** NULL/NaN 은 0 으로 본다 — 서버 `evaluate` 와 같은 규칙 */
|
|
294
|
+
function toNumber(value) {
|
|
295
|
+
if (value === null || value === undefined)
|
|
296
|
+
return 0;
|
|
297
|
+
if (typeof value === "number")
|
|
298
|
+
return Number.isNaN(value) ? 0 : value;
|
|
299
|
+
if (typeof value === "boolean")
|
|
300
|
+
return value ? 1 : 0;
|
|
301
|
+
if (value instanceof Date)
|
|
302
|
+
return value.getTime();
|
|
303
|
+
const parsed = Number(value);
|
|
304
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
305
|
+
}
|
|
306
|
+
function toDate(value) {
|
|
307
|
+
if (value instanceof Date)
|
|
308
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
309
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
310
|
+
const date = new Date(value);
|
|
311
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
function truncateDate(date, precision) {
|
|
316
|
+
const d = new Date(date.getTime());
|
|
317
|
+
switch (precision) {
|
|
318
|
+
case "year":
|
|
319
|
+
d.setMonth(0, 1);
|
|
320
|
+
d.setHours(0, 0, 0, 0);
|
|
321
|
+
break;
|
|
322
|
+
case "quarter":
|
|
323
|
+
d.setMonth(Math.floor(d.getMonth() / 3) * 3, 1);
|
|
324
|
+
d.setHours(0, 0, 0, 0);
|
|
325
|
+
break;
|
|
326
|
+
case "month":
|
|
327
|
+
d.setDate(1);
|
|
328
|
+
d.setHours(0, 0, 0, 0);
|
|
329
|
+
break;
|
|
330
|
+
case "week": {
|
|
331
|
+
// ISO 주 시작(월요일)
|
|
332
|
+
const day = (d.getDay() + 6) % 7;
|
|
333
|
+
d.setDate(d.getDate() - day);
|
|
334
|
+
d.setHours(0, 0, 0, 0);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "day":
|
|
338
|
+
d.setHours(0, 0, 0, 0);
|
|
339
|
+
break;
|
|
340
|
+
case "hour":
|
|
341
|
+
d.setMinutes(0, 0, 0);
|
|
342
|
+
break;
|
|
343
|
+
case "minute":
|
|
344
|
+
d.setSeconds(0, 0);
|
|
345
|
+
break;
|
|
346
|
+
case "second":
|
|
347
|
+
d.setMilliseconds(0);
|
|
348
|
+
break;
|
|
349
|
+
default: break;
|
|
350
|
+
}
|
|
351
|
+
return d;
|
|
352
|
+
}
|
|
353
|
+
function extractField(date, field) {
|
|
354
|
+
switch (field) {
|
|
355
|
+
case "year": return date.getFullYear();
|
|
356
|
+
case "quarter": return Math.floor(date.getMonth() / 3) + 1;
|
|
357
|
+
case "month": return date.getMonth() + 1;
|
|
358
|
+
case "week": {
|
|
359
|
+
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
360
|
+
const dayNum = target.getUTCDay() || 7;
|
|
361
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum);
|
|
362
|
+
const yearStart = Date.UTC(target.getUTCFullYear(), 0, 1);
|
|
363
|
+
return Math.ceil(((target.getTime() - yearStart) / 86_400_000 + 1) / 7);
|
|
364
|
+
}
|
|
365
|
+
case "day": return date.getDate();
|
|
366
|
+
case "hour": return date.getHours();
|
|
367
|
+
case "minute": return date.getMinutes();
|
|
368
|
+
case "second": return date.getSeconds();
|
|
369
|
+
case "dow": return date.getDay();
|
|
370
|
+
case "doy": {
|
|
371
|
+
const start = new Date(date.getFullYear(), 0, 0);
|
|
372
|
+
return Math.floor((date.getTime() - start.getTime()) / 86_400_000);
|
|
373
|
+
}
|
|
374
|
+
case "epoch": return Math.floor(date.getTime() / 1000);
|
|
375
|
+
default: return 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* 산술 수식을 행 값으로 평가한다. 0 으로 나누면 `null`.
|
|
380
|
+
* 검증을 통과한 식만 넣는다 — 문법 오류는 throw 한다.
|
|
381
|
+
*/
|
|
382
|
+
export function evaluateExpression(row, expr) {
|
|
383
|
+
const tokens = tokenizeExpression(normalizeExpression(expr));
|
|
384
|
+
validateTokens(tokens, null);
|
|
385
|
+
const postfix = toPostfix(tokens);
|
|
386
|
+
checkPostfix(postfix);
|
|
387
|
+
const stack = [];
|
|
388
|
+
const source = (row ?? {});
|
|
389
|
+
for (const token of postfix) {
|
|
390
|
+
switch (token.type) {
|
|
391
|
+
case "number":
|
|
392
|
+
stack.push(Number(token.value));
|
|
393
|
+
break;
|
|
394
|
+
case "identifier":
|
|
395
|
+
stack.push(readPath(source, token.value));
|
|
396
|
+
break;
|
|
397
|
+
case "string_literal":
|
|
398
|
+
stack.push(token.value);
|
|
399
|
+
break;
|
|
400
|
+
case "operator": {
|
|
401
|
+
// NULL 피연산자는 0 — 서버 `evaluate` 가 namespace 를 그렇게 채운다
|
|
402
|
+
const y = toNumber(stack.pop());
|
|
403
|
+
const x = toNumber(stack.pop());
|
|
404
|
+
switch (token.value) {
|
|
405
|
+
case "+":
|
|
406
|
+
stack.push(x + y);
|
|
407
|
+
break;
|
|
408
|
+
case "-":
|
|
409
|
+
stack.push(x - y);
|
|
410
|
+
break;
|
|
411
|
+
case "*":
|
|
412
|
+
stack.push(x * y);
|
|
413
|
+
break;
|
|
414
|
+
case "/":
|
|
415
|
+
stack.push(y === 0 ? null : x / y);
|
|
416
|
+
break;
|
|
417
|
+
default: break;
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "function": {
|
|
422
|
+
const column = stack.pop();
|
|
423
|
+
const literal = String(stack.pop() ?? "").toLowerCase();
|
|
424
|
+
const date = toDate(column);
|
|
425
|
+
if (!date) {
|
|
426
|
+
stack.push(null);
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
if (token.value === "DATE_TRUNC")
|
|
430
|
+
stack.push(truncateDate(date, literal).toISOString());
|
|
431
|
+
else
|
|
432
|
+
stack.push(extractField(date, literal));
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
default: break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return stack[0] ?? null;
|
|
439
|
+
}
|
|
440
|
+
/** 문자열 템플릿 평가 — `{field}` 를 행 값으로 치환한다. NULL 은 빈 문자열 */
|
|
441
|
+
export function evaluateTemplate(row, template) {
|
|
442
|
+
const source = (row ?? {});
|
|
443
|
+
return template.replace(TEMPLATE_FIELD_PATTERN, (_, field) => {
|
|
444
|
+
const value = readPath(source, field);
|
|
445
|
+
return value === null || value === undefined ? "" : String(value);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 타입에 맞춰 평가한다 — `string` 은 템플릿, 나머지는 산술.
|
|
450
|
+
* `boolean` 은 0/NULL 을 false 로 강제한다 (서버 `fill_virtual_columns` 와 같다).
|
|
451
|
+
*/
|
|
452
|
+
export function evaluateVirtualColumn(row, spec) {
|
|
453
|
+
const type = spec.type ?? "number";
|
|
454
|
+
if (type === "string")
|
|
455
|
+
return evaluateTemplate(row, spec.expr);
|
|
456
|
+
const value = evaluateExpression(row, spec.expr);
|
|
457
|
+
if (type === "boolean")
|
|
458
|
+
return value === null || value === undefined ? null : Boolean(value);
|
|
459
|
+
return value;
|
|
460
|
+
}
|
|
461
|
+
/** 응답 행마다 가상 컬럼 값을 얹는다 — 스토리의 로컬 서버가 쓴다 */
|
|
462
|
+
export function applyVirtualColumns(rows, spec) {
|
|
463
|
+
if (!spec || Object.keys(spec).length === 0)
|
|
464
|
+
return rows;
|
|
465
|
+
return rows.map((row) => {
|
|
466
|
+
const next = { ...row };
|
|
467
|
+
for (const [slot, column] of Object.entries(spec)) {
|
|
468
|
+
try {
|
|
469
|
+
next[slot] = evaluateVirtualColumn(row, column);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
next[slot] = null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return next;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/** 수식의 `{key}` / `key` 를 컬럼 헤더로 바꿔 사람이 읽을 형태로 만든다 (카드·미리보기용) */
|
|
479
|
+
export function describeExpression(expr, fields) {
|
|
480
|
+
const byKey = new Map(fields.map((f) => [f.key, f.header]));
|
|
481
|
+
const withBraces = expr.replace(TEMPLATE_FIELD_PATTERN, (whole, key) => byKey.get(key) ?? whole);
|
|
482
|
+
// 괄호 없이 쓴 식별자도 치환한다 — 긴 키부터 바꿔야 접두 충돌이 없다
|
|
483
|
+
const sorted = [...byKey.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
484
|
+
let result = withBraces;
|
|
485
|
+
for (const [key, header] of sorted) {
|
|
486
|
+
result = result.replace(new RegExp(`(?<![\\w.{])${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w.}])`, "g"), header);
|
|
487
|
+
}
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RowData } from "@tanstack/react-table";
|
|
2
|
+
import type { TableSearch, VirtualColumnSpec } from "../../../core/src/index";
|
|
3
|
+
import { type ExpressionValidation, type VirtualColumnField, type VirtualColumnType } from "./expression";
|
|
4
|
+
/** 가상 열 (사용자 정의 계산 컬럼) — 기존 ERP `VirtualColumn` 과 같은 모양 */
|
|
5
|
+
export interface VirtualColumn {
|
|
6
|
+
/** vc 슬롯 키 (`vc_1` …) — 요청 `virtualColumns` 의 키이자 응답 행의 필드명 */
|
|
7
|
+
slotKey: string;
|
|
8
|
+
/** 사용자 지정 이름 (예: "할인액"). 요청에는 실리지 않는다 — 헤더 표시용 */
|
|
9
|
+
name: string;
|
|
10
|
+
/** 계산 수식 (예: `{amount} * {quantity}`) */
|
|
11
|
+
expr: string;
|
|
12
|
+
type: VirtualColumnType;
|
|
13
|
+
}
|
|
14
|
+
export type VirtualColumnInput = Omit<VirtualColumn, "slotKey">;
|
|
15
|
+
export type VirtualColumnResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
slotKey: string;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export interface UseVirtualColumnsOptions {
|
|
23
|
+
/**
|
|
24
|
+
* true(기본) — 추가·수정·삭제가 `patchParams` 로 **즉시 적용**된다 (정렬·페이지처럼 버튼 없는 조작).
|
|
25
|
+
* false — `patchDraft` 에 쌓이고 `search.apply()` 가 반영한다 (필터와 함께 검색 버튼으로).
|
|
26
|
+
*/
|
|
27
|
+
applyImmediately?: boolean;
|
|
28
|
+
/** 슬롯 상한. 기본 10 (`vc_1` ~ `vc_10`) */
|
|
29
|
+
maxSlots?: number;
|
|
30
|
+
/** 슬롯 접두. 기본 `vc_` */
|
|
31
|
+
slotPrefix?: string;
|
|
32
|
+
/** 수식이 참조할 수 있는 필드. 주면 식별자 검증을 건다 (`virtualColumnFields(columns)`) */
|
|
33
|
+
fields?: ReadonlyArray<VirtualColumnField>;
|
|
34
|
+
/** 슬롯별 이름 초기값 — 이름은 검색 상태에 없으므로 URL 복원 등에서 따로 넘긴다 */
|
|
35
|
+
initialNames?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
export interface UseVirtualColumnsReturn<T extends RowData = RowData> {
|
|
38
|
+
search: TableSearch<T>;
|
|
39
|
+
/** 현재 가상 열 목록 (즉시 모드면 params, draft 모드면 draft 기준) */
|
|
40
|
+
columns: VirtualColumn[];
|
|
41
|
+
/** 추가 — 슬롯은 빈 것 중 가장 앞 번호. 검증 실패면 상태를 바꾸지 않고 이유를 돌려준다 */
|
|
42
|
+
add: (column: VirtualColumnInput) => VirtualColumnResult;
|
|
43
|
+
update: (slotKey: string, patch: Partial<VirtualColumnInput>) => VirtualColumnResult;
|
|
44
|
+
remove: (slotKey: string) => void;
|
|
45
|
+
/** 저장 전 검증 — 다이얼로그가 같은 규칙으로 미리 보여 준다 */
|
|
46
|
+
validate: (column: VirtualColumnInput, slotKey?: string) => ExpressionValidation;
|
|
47
|
+
/** 요청에 실리는 값 (`search.request.virtualColumns` 와 같다 — 즉시 모드 기준) */
|
|
48
|
+
request: Record<string, VirtualColumnSpec>;
|
|
49
|
+
isMaxReached: boolean;
|
|
50
|
+
/** 다음에 배정될 슬롯. 꽉 찼으면 null */
|
|
51
|
+
nextSlot: string | null;
|
|
52
|
+
applyImmediately: boolean;
|
|
53
|
+
/** 슬롯별 표시 이름 — `virtualColumnDefs(spec, { names })` 에 넘긴다 */
|
|
54
|
+
names: Record<string, string>;
|
|
55
|
+
fields: ReadonlyArray<VirtualColumnField>;
|
|
56
|
+
}
|
|
57
|
+
/** 슬롯 목록 `vc_1 … vc_N` (기존 ERP 규칙) */
|
|
58
|
+
export declare function virtualColumnSlots(maxSlots?: number, prefix?: string): string[];
|
|
59
|
+
/**
|
|
60
|
+
* 가상 열 상태 — `SearchState.virtualColumns` 위에서 CRUD 를 제공한다.
|
|
61
|
+
*
|
|
62
|
+
* 기존 ERP `useVirtualColumns` 는 목록을 로컬 state 로 들고 `virtualColumnsForRequest` 를
|
|
63
|
+
* 호출부가 요청에 직접 얹었다. 여기서는 검색 상태가 **원본**이다 — `search.request.virtualColumns`
|
|
64
|
+
* 로 자동으로 실리고, URL 동기화·프리셋·reset 도 같은 경로를 탄다. 이름만 로컬이다
|
|
65
|
+
* (요청 계약 `VirtualColumnSpec` 에 이름 자리가 없다).
|
|
66
|
+
*/
|
|
67
|
+
export declare function useVirtualColumns<T extends RowData>(search: TableSearch<T>, options?: UseVirtualColumnsOptions): UseVirtualColumnsReturn<T>;
|