feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user