400 lines
15 KiB
Vue
400 lines
15 KiB
Vue
<script setup>
|
||
import { ref, watch, onMounted } from 'vue';
|
||
import Pagination from './Pagination.vue';
|
||
import DataTable from 'primevue/datatable';
|
||
import Column from 'primevue/column';
|
||
import Button from 'primevue/button';
|
||
import InputText from 'primevue/inputtext';
|
||
import Toolbar from 'primevue/toolbar';
|
||
import Dropdown from 'primevue/dropdown';
|
||
import MultiSelect from 'primevue/multiselect';
|
||
|
||
const props = defineProps({
|
||
data: {
|
||
type: Array,
|
||
required: true,
|
||
default: () => []
|
||
},
|
||
columns: {
|
||
type: Array,
|
||
required: true,
|
||
default: () => []
|
||
},
|
||
selectable: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
modelValue: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
pagination: {
|
||
type: Object,
|
||
default: () => null
|
||
},
|
||
searchable: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
searchValue: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
filters: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
filterLoader: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['update:modelValue', 'selection-change', 'page-change', 'search-change', 'filter-change', 'apply-filters', 'clear-filters']);
|
||
|
||
const localSelection = ref(props.modelValue);
|
||
|
||
watch(() => props.modelValue, (newValue) => {
|
||
localSelection.value = newValue;
|
||
}, { immediate: true });
|
||
|
||
const handleSelectionChange = (event) => {
|
||
localSelection.value = event;
|
||
emit('update:modelValue', event);
|
||
emit('selection-change', event);
|
||
};
|
||
|
||
const localSearchValue = ref(props.searchValue);
|
||
|
||
watch(() => props.searchValue, (newValue) => {
|
||
localSearchValue.value = newValue;
|
||
});
|
||
|
||
let searchDebounceTimer = null;
|
||
|
||
const handleSearchInput = () => {
|
||
if (searchDebounceTimer) {
|
||
clearTimeout(searchDebounceTimer);
|
||
}
|
||
|
||
searchDebounceTimer = setTimeout(() => {
|
||
emit('search-change', localSearchValue.value);
|
||
}, 1500);
|
||
};
|
||
|
||
const localFilters = ref({ ...props.filters });
|
||
const dynamicOptions = ref({});
|
||
const loadingOptions = ref({});
|
||
const searchDebounceTimers = ref({});
|
||
const searchValues = ref({});
|
||
|
||
onMounted(() => {
|
||
props.columns.forEach(column => {
|
||
if (column.filter?.dynamic && props.filters[column.key]) {
|
||
const filterValue = props.filters[column.key];
|
||
searchValues.value[column.key] = filterValue.toString();
|
||
loadDynamicOptions(column, true, filterValue.toString(), true);
|
||
}
|
||
else if (column.filter?.dynamic) {
|
||
searchValues.value[column.key] = '';
|
||
loadDynamicOptions(column, true, '', false);
|
||
}
|
||
});
|
||
});
|
||
|
||
watch(() => props.filters, (newValue) => {
|
||
Object.keys(newValue).forEach(key => {
|
||
if (newValue[key] !== undefined && newValue[key] !== null) {
|
||
localFilters.value[key] = newValue[key];
|
||
}
|
||
});
|
||
}, { immediate: true, deep: true });
|
||
|
||
const handleFilterChange = (columnKey, value) => {
|
||
localFilters.value[columnKey] = value;
|
||
emit('filter-change', { ...localFilters.value });
|
||
};
|
||
|
||
const clearAllFilters = () => {
|
||
localFilters.value = {};
|
||
dynamicOptions.value = {};
|
||
searchValues.value = {};
|
||
emit('clear-filters');
|
||
};
|
||
|
||
const loadDynamicOptions = async (column, forceReload = false, searchTerm = '', shouldSelectValue = false) => {
|
||
if (!column.filter?.dynamic || !column.filter?.url) return;
|
||
|
||
const columnKey = column.key;
|
||
loadingOptions.value[columnKey] = true;
|
||
|
||
try {
|
||
let url = column.filter.url;
|
||
if (searchTerm) {
|
||
url += `?search=${encodeURIComponent(searchTerm)}`;
|
||
}
|
||
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.data) {
|
||
dynamicOptions.value[columnKey] = data.data;
|
||
|
||
if (shouldSelectValue && props.filters[columnKey]) {
|
||
const filterValue = props.filters[columnKey];
|
||
const foundOption = data.data.find(option => option.value == filterValue);
|
||
if (foundOption) {
|
||
localFilters.value[columnKey] = foundOption.value;
|
||
}
|
||
}
|
||
} else {
|
||
dynamicOptions.value[columnKey] = [];
|
||
}
|
||
} catch (error) {
|
||
console.error('Error:', error);
|
||
dynamicOptions.value[columnKey] = [];
|
||
} finally {
|
||
loadingOptions.value[columnKey] = false;
|
||
}
|
||
};
|
||
|
||
|
||
const handleFilterSearch = (column, searchTerm) => {
|
||
if (!column.filter?.dynamic || !column.filter?.search) return;
|
||
|
||
const columnKey = column.key;
|
||
searchValues.value[columnKey] = searchTerm;
|
||
|
||
if (searchDebounceTimers.value[columnKey]) {
|
||
clearTimeout(searchDebounceTimers.value[columnKey]);
|
||
}
|
||
|
||
searchDebounceTimers.value[columnKey] = setTimeout(() => {
|
||
loadDynamicOptions(column, true, searchTerm, false);
|
||
}, 1500);
|
||
};
|
||
|
||
const renderFilter = (column) => {
|
||
if (!column.filter) return null;
|
||
|
||
const filterConfig = column.filter;
|
||
const currentValue = localFilters.value[column.key] || null;
|
||
const columnKey = column.key;
|
||
|
||
let options = [];
|
||
if (filterConfig.dynamic) {
|
||
options = dynamicOptions.value[columnKey] || [];
|
||
} else if (filterConfig.options) {
|
||
options = filterConfig.options;
|
||
}
|
||
|
||
if (filterConfig.type === 'dropdown') {
|
||
return {
|
||
type: 'dropdown',
|
||
options: loadingOptions.value[columnKey] ? [{ label: 'در حال پردازش...', value: null, disabled: true }] : options,
|
||
value: currentValue,
|
||
placeholder: filterConfig.placeholder || 'انتخاب کنید...',
|
||
loading: loadingOptions.value[columnKey] || false,
|
||
dynamic: filterConfig.dynamic || false,
|
||
search: filterConfig.search || false,
|
||
searchValue: searchValues.value[columnKey] || ''
|
||
};
|
||
} else if (filterConfig.type === 'multiselect') {
|
||
return {
|
||
type: 'multiselect',
|
||
options: loadingOptions.value[columnKey] ? [{ label: 'در حال پردازش...', value: null, disabled: true }] : options,
|
||
value: currentValue || [],
|
||
placeholder: filterConfig.placeholder || 'انتخاب کنید...',
|
||
loading: loadingOptions.value[columnKey] || false,
|
||
dynamic: filterConfig.dynamic || false,
|
||
search: filterConfig.search || false,
|
||
searchValue: searchValues.value[columnKey] || ''
|
||
};
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const renderColumnContent = (column, data) => {
|
||
let content = '';
|
||
if (column.render && typeof column.render === 'function') {
|
||
content = column.render(data);
|
||
} else {
|
||
content = data[column.key];
|
||
}
|
||
|
||
const defaultStyle = '!font-medium !text-sm';
|
||
const customStyle = column.style || defaultStyle;
|
||
|
||
return `<span class="${customStyle} text-right wrap-break-word text-sm">${content}</span>`;
|
||
};
|
||
|
||
const getColumnStyle = (column) => {
|
||
const baseStyle = { minWidth: '120px' };
|
||
|
||
if (column.width) {
|
||
baseStyle.width = column.width + 'px';
|
||
baseStyle.minWidth = column.width + 'px';
|
||
baseStyle.maxWidth = column.width + 'px';
|
||
}
|
||
|
||
return baseStyle;
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<div class="table-generic">
|
||
<div v-if="searchable" class="p-4 pb-2">
|
||
<Toolbar class="">
|
||
<template #start>
|
||
<div class="grid grid-cols-2a max-sm:grid-cols-1 justify-start gap-2">
|
||
<div class="grid grid-cols-1 items-center justify-start">
|
||
<InputText
|
||
size="small"
|
||
placeholder="جستجو..."
|
||
type="text"
|
||
v-model="localSearchValue"
|
||
@input="handleSearchInput"
|
||
/>
|
||
</div>
|
||
<div v-if="columns.some(col => col.filter)" class="">
|
||
<div class="flex flex-wrap max-sm:!flex-col gap-2 py-2">
|
||
<div
|
||
v-for="column in columns.filter(col => col.filter)"
|
||
:key="`filter-${column.key}`"
|
||
class="flex items-center gap-2"
|
||
>
|
||
|
||
<Dropdown
|
||
v-if="renderFilter(column)?.type === 'dropdown'"
|
||
:modelValue="renderFilter(column).value"
|
||
:options="renderFilter(column).options"
|
||
:placeholder="renderFilter(column).loading ? 'در حال بارگذاری...' : renderFilter(column).placeholder"
|
||
optionLabel="label"
|
||
optionValue="value"
|
||
size="small"
|
||
class="min-w-[130px] max-sm:!w-full max-w-[200px]"
|
||
:loading="renderFilter(column).loading"
|
||
:disabled="renderFilter(column).loading"
|
||
:filter="renderFilter(column).search"
|
||
:filterValue="renderFilter(column).searchValue"
|
||
:filterPlaceholder="renderFilter(column).search ? 'جستجو...' : ''"
|
||
emptyMessage="داده ای یافت نشد"
|
||
:filterMatchModeOptions="[]"
|
||
:filterFunction="() => true"
|
||
@update:modelValue="(value) => handleFilterChange(column.key, value)"
|
||
@filter="(event) => handleFilterSearch(column, event.value)"
|
||
showClear
|
||
/>
|
||
|
||
<MultiSelect
|
||
v-else-if="renderFilter(column)?.type === 'multiselect'"
|
||
:modelValue="renderFilter(column).value"
|
||
:options="renderFilter(column).options"
|
||
:placeholder="renderFilter(column).loading ? 'در حال بارگذاری...' : renderFilter(column).placeholder"
|
||
optionLabel="label"
|
||
optionValue="value"
|
||
size="small"
|
||
class="min-w-[150px] max-sm:!w-full max-w-[200px]"
|
||
:loading="renderFilter(column).loading"
|
||
:disabled="renderFilter(column).loading"
|
||
:filter="renderFilter(column).search"
|
||
:filterValue="renderFilter(column).searchValue"
|
||
:filterPlaceholder="renderFilter(column).search ? 'جستجو...' : ''"
|
||
emptyMessage="داده ای یافت نشد"
|
||
:filterMatchModeOptions="[]"
|
||
:filterFunction="() => true"
|
||
@update:modelValue="(value) => handleFilterChange(column.key, value)"
|
||
@filter="(event) => handleFilterSearch(column, event.value)"
|
||
showClear
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<Button
|
||
:label="filterLoader ? 'در حال پردازش...' : 'اعمال فیلتر'"
|
||
size="small"
|
||
severity="primary"
|
||
class="!text-xs h-10"
|
||
:loading="filterLoader"
|
||
:disabled="filterLoader"
|
||
@click="$emit('apply-filters')"
|
||
/>
|
||
<Button
|
||
label="پاک کردن"
|
||
size="small"
|
||
severity="secondary"
|
||
class="!text-xs h-10"
|
||
outlined
|
||
@click="clearAllFilters"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="localSelection && localSelection.length > 0" class="mr-2 !h-9 grid grid-cols-1">
|
||
<slot name="bulk-action" :selection="localSelection"></slot>
|
||
</div>
|
||
</template>
|
||
|
||
<template #end>
|
||
<slot name="toolbar-actions"></slot>
|
||
</template>
|
||
</Toolbar>
|
||
</div>
|
||
|
||
<DataTable
|
||
:value="data"
|
||
dataKey="id"
|
||
v-model:selection="localSelection"
|
||
:showGridlines="false"
|
||
:stripedRows="false"
|
||
@selection-change="handleSelectionChange"
|
||
>
|
||
<template #empty>
|
||
<div class="flex justify-center py-4 items-center h-full">
|
||
<span class="text-center text-sm">هیچ دادهای پیدا نشده!</span>
|
||
</div>
|
||
</template>
|
||
|
||
<Column
|
||
v-if="selectable"
|
||
selectionMode="multiple"
|
||
headerStyle="width: 4rem"
|
||
/>
|
||
|
||
<Column
|
||
v-for="column in columns"
|
||
:key="column.key"
|
||
:field="column.key"
|
||
:header="column.title"
|
||
:sortable="column.hasSort === true"
|
||
:style="getColumnStyle(column)"
|
||
>
|
||
<template #body="{ data }">
|
||
<slot v-if="column.slot" :name="`column-${column.key}`" :data="data" :column="column">
|
||
<div v-html="renderColumnContent(column, data)"></div>
|
||
</slot>
|
||
<div v-else v-html="renderColumnContent(column, data)"></div>
|
||
</template>
|
||
</Column>
|
||
</DataTable>
|
||
|
||
<Pagination
|
||
v-if="pagination && pagination.total"
|
||
:total="pagination.total"
|
||
:current-page="pagination.current_page"
|
||
:perPage="pagination.per_page"
|
||
:lastPage="pagination.last_page"
|
||
:links="pagination.links"
|
||
@page-change="(page) => emit('page-change', page)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.table-generic {
|
||
width: 100%;
|
||
}
|
||
</style>
|