feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -0,0 +1,87 @@
<template>
<!--
Ported from legacy henza_admin/src/components/input/DateInput.vue — the
Jalali date picker used by the report filters.
Legacy wrapped vue-persian-datetime-picker, which has no Vue 3 build. This
uses ant's picker and converts through moment-jalaali, so the operator still
types and reads Jalali (e.g. ۱۴۰۳/۰۵/۱۲) while the value emitted stays an
ISO date the API can filter on.
-->
<a-form-item :label="label">
<a-input
:value="displayValue"
:placeholder="placeholder"
allow-clear
@update:value="onType"
/>
<small v-if="hint" class="hint">{{ hint }}</small>
</a-form-item>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import moment from 'moment-jalaali';
const props = withDefaults(
defineProps<{
label?: string;
/** ISO date string, or undefined when empty. */
modelValue?: string;
placeholder?: string;
}>(),
{ placeholder: 'مثال: ۱۴۰۳/۰۵/۱۲' },
);
const emit = defineEmits<{ (e: 'update:modelValue', v: string | undefined): void }>();
const typed = ref<string | undefined>(undefined);
const hint = ref('');
/** ISO in -> Jalali out, for display. */
const displayValue = computed(() => {
if (typed.value !== undefined) return typed.value;
if (!props.modelValue) return '';
const m = moment(props.modelValue);
return m.isValid() ? m.format('jYYYY/jMM/jDD') : '';
});
watch(
() => props.modelValue,
() => {
// A change from outside overrides whatever was half-typed.
typed.value = undefined;
},
);
/**
* Accepts Jalali (۱۴۰۳/۰۵/۱۲ or 1403/05/12) and emits the ISO equivalent.
* Persian digits are normalised first so either numeral set works.
*/
function onType(raw: string) {
typed.value = raw;
if (!raw) {
hint.value = '';
emit('update:modelValue', undefined);
return;
}
const latin = raw.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
const m = moment(latin, 'jYYYY/jMM/jDD');
if (m.isValid()) {
hint.value = '';
emit('update:modelValue', m.format('YYYY-MM-DD'));
} else {
hint.value = 'تاریخ معتبر نیست';
}
}
</script>
<style scoped>
.hint {
color: #ea5455;
font-size: 11px;
}
</style>
@@ -0,0 +1,101 @@
<template>
<!--
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
The legacy component renders an ApexCharts sparkline under the figure, but
EVERY live caller passes :hideChart="true" (verified: 3 of 3 on the wholesale
dashboard), and the only apexchart markup in either dashboard sits inside
commented-out Vuexy demo cards. So the chart branch is not ported and
vue-apexcharts is not a dependency — adding it would ship a library nothing
renders.
-->
<a-card class="stat-card">
<div class="stat-row">
<span class="stat-icon" :class="`icon-${color}`">
<component :is="iconComponent" />
</span>
<div>
<h2 class="stat-value">{{ statistic }}</h2>
<span class="stat-title">{{ statisticTitle }}</span>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
ShoppingOutlined,
TeamOutlined,
DollarOutlined,
BarChartOutlined,
} from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
icon?: string;
statistic?: string | number;
statisticTitle?: string;
color?: string;
/** Kept for call-site parity; the chart branch is not ported (see above). */
hideChart?: boolean;
}>(),
{ color: 'primary', hideChart: true },
);
/** feather name -> ant icon, matching the icons the live cards ask for */
const ICONS: Record<string, unknown> = {
UsersIcon: TeamOutlined,
ShoppingBagIcon: ShoppingOutlined,
DollarSignIcon: DollarOutlined,
BarChartIcon: BarChartOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
</script>
<style scoped>
.stat-card {
height: 100%;
}
.stat-row {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 22px;
flex-shrink: 0;
}
.icon-primary {
background: rgba(28, 45, 86, 0.1);
color: #1c2d56;
}
.icon-warning {
background: rgba(255, 159, 67, 0.12);
color: #ff9f43;
}
.icon-success {
background: rgba(40, 199, 111, 0.12);
color: #28c76f;
}
.icon-danger {
background: rgba(234, 84, 85, 0.12);
color: #ea5455;
}
.stat-value {
margin: 0;
font-size: 20px;
color: #1c2d56;
}
.stat-title {
font-size: 12px;
color: #888;
}
</style>
@@ -0,0 +1,80 @@
<template>
<!--
Ported from legacy henza_admin/src/components/tooltip-btn/TooltipBtn.vue.
Same API (title, popTitle, icon, iconClass) and same behaviour: with popTitle
it confirms first, without it fires immediately.
Legacy rendered <feather-icon icon="ListIcon">. vue-feather has no Vue 3
build, so the feather NAME is mapped to an ant-design icon here. Ported views
keep writing icon="ListIcon" unchanged.
-->
<div>
<a-tooltip :title="title">
<a-popconfirm
v-if="popTitle"
ok-text="بله"
cancel-text="خیر"
:title="popTitle"
@confirm="onClick"
>
<component :is="iconComponent" class="cursor-pointer" :class="iconClass" />
</a-popconfirm>
<div v-else>
<component
:is="iconComponent"
class="cursor-pointer"
:class="iconClass"
@click="onClick"
/>
</div>
</a-tooltip>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
CameraOutlined,
CheckOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
FileOutlined,
FileTextOutlined,
PlusOutlined,
UnorderedListOutlined,
CloseOutlined,
} from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
title?: string;
popTitle?: string;
icon?: string;
iconClass?: string;
}>(),
{ iconClass: 'w-5 h-5' },
);
const emit = defineEmits<{ (e: 'click'): void }>();
/** feather name -> ant component */
const ICONS: Record<string, unknown> = {
ListIcon: UnorderedListOutlined,
FileIcon: FileOutlined,
TrashIcon: DeleteOutlined,
EditIcon: EditOutlined,
CheckIcon: CheckOutlined,
CameraIcon: CameraOutlined,
EyeIcon: EyeOutlined,
PlusIcon: PlusOutlined,
XIcon: CloseOutlined,
FileTextIcon: FileTextOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? UnorderedListOutlined);
function onClick() {
emit('click');
}
</script>
@@ -0,0 +1,119 @@
<template>
<!--
Ports legacy henza_admin/src/components/input/SingleUploadInput.vue (and the
near-identical MyUpload.vue / UploadInput.vue), which posted multipart to
`file/create` and emitted the response once it carried an `id`.
Legacy shipped FOUR of these wrappers differing only in list type and the
emitted event; this is the single shared version, matching how the rest of
this port deduplicates legacy's copy-paste components.
The bound value is the stored file's URL, which is what the schema keeps.
-->
<div class="image-upload">
<a-upload
:file-list="fileList"
list-type="picture-card"
:max-count="1"
accept="image/*"
:custom-request="doUpload"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="fileList.length === 0">
<a-spin v-if="uploading" size="small" />
<div v-else>
<plus-outlined />
<div style="margin-top: 8px">{{ placeholder }}</div>
</div>
</div>
</a-upload>
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
<img :src="previewSrc" style="width: 100%" alt="" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
/** The stored file URL. */
modelValue?: string | null;
/** Free-form tag recorded on the upload, e.g. 'category' or 'product'. */
purpose?: string;
placeholder?: string;
}>(),
{ modelValue: null, purpose: undefined, placeholder: 'بارگذاری' },
);
const emit = defineEmits<{
(e: 'update:modelValue', v: string | null): void;
/** Legacy emitted the whole response object as `ok`. */
(e: 'ok', file: Record<string, unknown>): void;
}>();
const uploading = ref(false);
const previewVisible = ref(false);
const previewSrc = ref('');
interface UiFile {
uid: string;
name: string;
status: 'done';
url: string;
}
const fileList = ref<UiFile[]>([]);
/** Keeps the thumbnail in step when the parent loads an existing record. */
watch(
() => props.modelValue,
(url) => {
if (!url) {
fileList.value = [];
} else if (fileList.value[0]?.url !== url) {
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
}
},
{ immediate: true },
);
const placeholder = computed(() => props.placeholder);
async function doUpload({ file }: { file: File }) {
const form = new FormData();
form.append('file', file);
if (props.purpose) form.append('purpose', props.purpose);
uploading.value = true;
try {
// Let the browser set the multipart boundary — overriding Content-Type here
// is the classic way to break a multipart POST.
const { data } = await api.post('file/create', form);
emit('update:modelValue', data.url);
emit('ok', data);
} catch (e) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'بارگذاری فایل ناموفق بود';
message.error(msg);
} finally {
uploading.value = false;
}
}
function handleRemove() {
emit('update:modelValue', null);
return true;
}
function handlePreview(file: UiFile) {
previewSrc.value = file.url;
previewVisible.value = true;
}
</script>
@@ -0,0 +1,365 @@
<template>
<!--
Ported from legacy henza_admin/src/components/x-table/XTable.vue (572 lines).
Preserved: the server contract (POST {search, filters, page, limit, select,
titles, report_title}), the { rows, query } response shape, the filter bar,
the column show/hide picker, search, sorting and the named cell slots.
Dropped: the fullscreen toggle and the excel/json/print export buttons —
exports needed a legacy endpoint that returned a file url, which the new
backend does not implement. The buttons are omitted rather than left as
dead controls that silently fail.
-->
<div class="x-table">
<div class="x-table__toolbar">
<a-input-search
v-if="!model._hideSearch"
v-model:value="query.search"
placeholder="جستجو"
allow-clear
style="max-width: 260px"
@search="onSearch"
/>
<div class="x-table__actions">
<a-button v-if="!model._noFilters" :type="activeFilter ? 'primary' : 'default'" @click="toggleVisibleFilter">
فیلتر
<a-badge v-if="query.filters.length" :count="query.filters.length" />
</a-button>
<a-dropdown :trigger="['click']">
<a-button>ستون‌ها</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-for="col in cols" :key="col.key">
<a-checkbox v-model:checked="col.show">{{ col.title }}</a-checkbox>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button class="refresh-btn" :loading="loading" @click="fetch()">بارگذاری مجدد</a-button>
</div>
</div>
<!-- filter bar: one row per active filter -->
<div v-if="activeFilter" class="x-table__filters">
<div v-for="(f, index) in query.filters" :key="f.id" class="x-table__filter-row">
<a-select
v-model:value="f.field"
placeholder="ستون"
style="width: 180px"
:options="filterableOptions"
/>
<a-select v-model:value="f.op" placeholder="شرط" style="width: 130px" :options="opOptions" />
<!--
A column declared with filterSelect({...}) gets a dropdown of its
value->label map; a date column gets a date picker; everything else
gets a free-text box, as in legacy.
-->
<a-select
v-if="selectItemsFor(f.field)"
v-model:value="f.value"
placeholder="مقدار"
style="width: 200px"
:options="selectItemsFor(f.field)"
/>
<a-date-picker
v-else-if="isDateField(f.field)"
v-model:value="f.value"
placeholder="مقدار"
style="width: 200px"
value-format="YYYY-MM-DD"
/>
<a-input v-else v-model:value="f.value" placeholder="مقدار" style="width: 200px" />
<a-input
v-if="f.op === 'between'"
v-model:value="f.value2"
placeholder="تا"
style="width: 200px"
/>
<a-button danger @click="removeFilter(index)">حذف</a-button>
</div>
<div class="x-table__filter-actions">
<a-button @click="addFilterRow">افزودن فیلتر</a-button>
<a-button type="primary" @click="doFilter">اعمال</a-button>
</div>
</div>
<a-table
:columns="antColumns"
:data-source="data"
:loading="loading"
:pagination="paginationConfig"
:locale="locale"
:row-selection="rowSelection"
row-key="id"
size="middle"
bordered
@change="onTableChange"
>
<template #bodyCell="{ column, record, text }">
<!--
Legacy used named scoped slots (renderSlot('opt') -> slot="opt").
Vue 3 routes them through #bodyCell, and the slot name is forwarded to
the parent under the same name, so ported views keep their markup.
-->
<template v-if="slotNameFor(column)">
<slot :name="slotNameFor(column)" :text="text" :record="record" />
</template>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import api from '../../api/client';
import type { XTbl } from './model';
const props = withDefaults(
defineProps<{
model: XTbl;
/** legacy prop: renders a checkbox column (ProductList uses this) */
hasSelection?: boolean;
/** legacy used v-model for the selected rows */
modelValue?: Record<string, unknown>[];
}>(),
{ hasSelection: false, modelValue: () => [] },
);
const emit = defineEmits<{
(e: 'query', q: unknown): void;
(e: 'update:modelValue', rows: Record<string, unknown>[]): void;
}>();
/** Only present when hasSelection is set, so other tables are unaffected. */
const rowSelection = computed(() =>
props.hasSelection
? {
selectedRowKeys: props.modelValue.map((r) => r.id as string),
onChange: (_keys: (string | number)[], rows: Record<string, unknown>[]) => {
emit('update:modelValue', rows);
},
}
: undefined,
);
interface FilterRow {
id: number;
field?: string;
op?: string;
value?: unknown;
value2?: unknown;
}
const data = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
const activeFilter = ref(false);
const sort = ref<string | undefined>(undefined);
const sortDirection = ref<'asc' | 'desc'>('asc');
// legacy data.query
const query = reactive({
search: undefined as string | undefined,
filters: [] as FilterRow[],
total: 0,
limit: 10,
page: 1,
});
const locale = { emptyText: 'هیج رکوردی یافت نشد.' };
const opOptions = [
{ value: '=', label: 'برابر' },
{ value: '!=', label: 'نامساوی' },
{ value: 'like', label: 'شامل' },
{ value: '>', label: 'بزرگتر' },
{ value: '>=', label: 'بزرگتر مساوی' },
{ value: '<', label: 'کوچکتر' },
{ value: '<=', label: 'کوچکتر مساوی' },
{ value: 'between', label: 'بین' },
];
const cols = computed(() => props.model?.cols ?? []);
const visibleCols = computed(() => cols.value.filter((c) => c.show));
const filterableOptions = computed(() =>
cols.value.filter((c) => c.filtered).map((c) => ({ value: c.__fkey ?? c.key, label: c.title })),
);
/** legacy `order` computed: '+field' / '-field' */
const order = computed(() =>
sort.value ? `${sortDirection.value === 'asc' ? '+' : '-'}${sort.value}` : undefined,
);
const antColumns = computed(() =>
visibleCols.value.map((c) => ({
key: c.key,
title: c.title,
dataIndex: c.dataIndex.includes('.') ? c.dataIndex.split('.') : c.dataIndex,
sorter: c.__sorter,
width: c.width,
__slot: c.scopedSlots?.customRender,
})),
);
function slotNameFor(column: { __slot?: string }) {
return column.__slot;
}
/** Resolves a filter row's column back to its Xtc definition. */
function colForField(field?: string) {
if (!field) return undefined;
return cols.value.find((c) => (c.__fkey ?? c.key) === field);
}
/** Dropdown options for a filterSelect column, or undefined for other types. */
function selectItemsFor(field?: string) {
const col = colForField(field);
if (col?.filterType !== 'select' || !col.filterItems) return undefined;
return Object.entries(col.filterItems).map(([value, label]) => ({ value, label }));
}
function isDateField(field?: string) {
return colForField(field)?.filterType === 'date';
}
const paginationConfig = computed(() =>
props.model?._hidePagination
? false
: {
current: query.page,
pageSize: query.limit,
total: query.total,
showSizeChanger: true,
},
);
/** legacy requestData() */
function requestData(op?: string) {
const titles: Record<string, string> = {};
visibleCols.value.forEach((c) => {
titles[c.key] = c.title;
});
const select = visibleCols.value.map((c) => c.key);
const base = {
search: query.search,
filters: query.filters.filter((f) => f.field && f.op),
total: query.total,
limit: query.limit,
page: query.page,
order: order.value,
select,
titles,
report_title: props.model?.title,
};
return op ? { ...base, op } : base;
}
/** legacy fetch() + getDataUsage() in 'laravel' mode */
async function fetch(op?: string) {
if (!props.model) return;
try {
loading.value = true;
const { data: res } = await api.post(props.model.url, requestData(op));
data.value = res.rows;
query.total = res.query?.total ?? 0;
query.page = res.query?.page ?? query.page;
query.limit = res.query?.limit ?? query.limit;
emit('query', res.query);
} catch (error) {
console.log(error);
throw error;
} finally {
loading.value = false;
}
}
function onSearch(value: string) {
query.search = value;
query.page = 1;
fetch();
}
function toggleVisibleFilter() {
activeFilter.value = !activeFilter.value;
if (activeFilter.value && query.filters.length === 0) addFilterRow();
}
/** legacy addFilterRow(): id is a random float used only as a list key */
function addFilterRow() {
query.filters.push({ id: parseFloat(`${query.filters.length}.${Math.floor(Math.random() * 1000)}`) });
}
function removeFilter(index: number) {
query.filters.splice(index, 1);
}
function doFilter() {
activeFilter.value = false;
query.page = 1;
fetch();
}
function onTableChange(
pagination: { current?: number; pageSize?: number },
_filters: unknown,
sorter: { columnKey?: string; order?: string },
) {
query.page = pagination.current ?? 1;
query.limit = pagination.pageSize ?? query.limit;
if (sorter?.columnKey && sorter.order) {
sort.value = String(sorter.columnKey);
sortDirection.value = sorter.order === 'ascend' ? 'asc' : 'desc';
} else {
sort.value = undefined;
}
fetch();
}
onMounted(() => {
// legacy applied model.defaultFilters before the first fetch
for (const df of props.model?.defaultFilters ?? []) {
query.filters.push({ id: Math.random(), ...df });
}
fetch();
});
defineExpose({ fetch });
</script>
<style scoped>
.x-table__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.x-table__actions {
display: flex;
gap: 8px;
}
.x-table__filters {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.x-table__filter-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.x-table__filter-actions {
display: flex;
gap: 8px;
}
.refresh-btn {
transition: all 0.15s ease;
}
</style>
@@ -0,0 +1,191 @@
/**
* Ported from legacy henza_admin/src/components/x-table/model/{XTbl,Xtc}.js
* and src/table/index.js (ColCreator).
*
* The fluent column API is preserved exactly — `new Xtc(key, title).filterText()
* .renderSlot('x').noSort()` builds the same shape the legacy table schemas used,
* so the 18 schema files port over with only their import path changed.
*
* Legacy had FOUR near-duplicate XTable forks (x-table/, x-table_select/,
* custom-xtable-select/, backup-table/, ~1,300 LOC). Per the agreed decision this
* port keeps ONE — the x-table/ variant, which is the most complete (it is the
* only one with QuerySelectTableFilter).
*/
export type FilterType = 'default' | 'switch' | 'query' | 'date' | 'number' | 'select';
export interface ColumnSlots {
customRender?: string;
filterDropdown?: string;
filterIcon?: string;
}
/** Port of legacy Xtc — one column definition. */
export class Xtc {
key: string;
title: string;
dataIndex: string;
show = true;
/** legacy `__sorter`: sortable unless noSort() was called */
__sorter = true;
filtered = false;
filterType?: FilterType;
filterAction?: string;
/** legacy `filterItems`: a value -> label map for select filters */
filterItems?: Record<string | number, string>;
scopedSlots?: ColumnSlots;
/** legacy filterKey()/master — filter against a different field than dataIndex */
__fkey?: string;
width?: string | number;
constructor(key: string, title: string) {
this.key = key;
this.title = title;
this.dataIndex = key;
}
private __filter() {
this.filtered = true;
if (!this.scopedSlots) this.scopedSlots = {};
this.scopedSlots.filterDropdown = `${this.key}_filterDropdown`;
this.scopedSlots.filterIcon = `${this.key}_filterIcon`;
}
/** legacy filterKey(key) */
filterKey(key: string) {
this.__fkey = key;
return this;
}
/** legacy renderSlot(name) — render this cell through a named slot */
renderSlot(name: string) {
if (!this.scopedSlots) this.scopedSlots = {};
this.scopedSlots.customRender = name;
return this;
}
filterText() {
this.__filter();
this.filterType = 'default';
return this;
}
filterSwitch() {
this.__filter();
this.filterType = 'switch';
return this;
}
filterQuery(action: string) {
this.__filter();
this.filterType = 'query';
this.filterAction = action;
return this;
}
filterDate() {
this.__filter();
this.filterType = 'date';
return this;
}
filterNumber() {
this.__filter();
this.filterType = 'number';
return this;
}
/**
* legacy filterSelect(items) — `items` is an OBJECT MAP of value -> label,
* e.g. { 0: 'معلق', 1: 'ثبت سفارش' }, stored on `filterItems`. The shape is
* kept exactly so the ported table models pass their maps unchanged.
*
* Legacy also permitted calling it with no argument at all (TaminListModel does
* this), which left filterItems undefined and rendered an empty dropdown.
* Preserved: the parameter is optional here too.
*/
filterSelect(items?: Record<string | number, string>) {
this.__filter();
this.filterType = 'select';
this.filterItems = items;
return this;
}
/** legacy noSort() */
noSort() {
this.__sorter = false;
return this;
}
/** legacy hidden() — column exists but starts unchecked in the column picker */
hidden() {
this.show = false;
return this;
}
setWidth(w: string | number) {
this.width = w;
return this;
}
}
export interface DefaultFilter {
field: string;
op: string;
value: unknown;
}
/** Port of legacy XTbl — a table definition: endpoint + title + columns. */
export class XTbl {
url: string;
title: string;
cols: Xtc[] = [];
defaultFilters?: DefaultFilter[];
_hidePagination = false;
_hideSearch = false;
_noFilters = false;
constructor(url: string, title: string) {
this.url = url;
this.title = title;
}
add(col: Xtc) {
this.cols.push(col);
return this;
}
addDefaultFilter(filter: DefaultFilter) {
if (!this.defaultFilters) this.defaultFilters = [];
this.defaultFilters.push(filter);
return this;
}
hidePagination() {
this._hidePagination = true;
return this;
}
/**
* NOTE: legacy's hideSearch() set `_hidePageination` (sic) instead of a search
* flag — a copy-paste bug, so calling it hid PAGINATION, not the search box.
* Fixed here to set the intended flag; no ported schema calls it, so nothing
* changes in behaviour. Recorded in docs/ADMIN-PORT-PLAN.md.
*/
hideSearch() {
this._hideSearch = true;
return this;
}
/** legacy noFilters(): hides the filter bar entirely (walletTbl uses this). */
noFilters() {
this._noFilters = true;
return this;
}
getSortFields() {
return this.cols.filter((c) => c.__sorter);
}
}
export default XTbl;