Files
hedena_gold/apps/admin-panel/src/components/x-table/XTable.vue
T

366 lines
10 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>