Initial commit: admin dashboard for GameNo.
Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<!-- /src/components/common/DataTableWrapper.vue -->
|
||||
<template>
|
||||
<div class="data-table-wrapper surface-card p-4 border-round border-1 border-color shadow-sm">
|
||||
<!-- Toolbar / Search / Filter Header -->
|
||||
<div class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mb-4">
|
||||
<div class="p-input-icon-right w-full sm:w-20rem">
|
||||
<IconField iconPosition="right">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="searchValue"
|
||||
:placeholder="$t('app.search')"
|
||||
class="w-full text-sm"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<div class="flex align-items-center gap-2 w-full sm:w-auto justify-content-end">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton loading -->
|
||||
<TableSkeleton v-if="loading" :rows="skeletonRowCount" :columns="skeletonColumnCount" />
|
||||
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
v-else
|
||||
:value="items"
|
||||
:lazy="true"
|
||||
:sortField="sortBy"
|
||||
:sortOrder="sortOrder === 'asc' ? 1 : -1"
|
||||
:selection="selection"
|
||||
dataKey="_id"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm text-sm"
|
||||
@sort="onSort"
|
||||
@update:selection="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<EmptyState />
|
||||
</template>
|
||||
|
||||
<Column v-if="selectable" selectionMode="multiple" headerStyle="width: 3rem" />
|
||||
<slot />
|
||||
</DataTable>
|
||||
|
||||
<!-- Custom Paginator -->
|
||||
<div
|
||||
class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mt-4 pt-3 border-top-1 border-color"
|
||||
v-if="totalCount > 0 || loading"
|
||||
>
|
||||
<span class="text-muted text-sm">
|
||||
<template v-if="loading">در حال بارگذاری…</template>
|
||||
<template v-else>
|
||||
نمایش {{ toPersianDigits(firstItemIndex) }} تا {{ toPersianDigits(lastItemIndex) }} از {{ toPersianDigits(totalCount) }} مورد
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<Paginator
|
||||
:rows="limit"
|
||||
:totalRecords="Math.max(totalCount, 1)"
|
||||
:first="(page - 1) * limit"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
:disabled="loading"
|
||||
@page="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Paginator from 'primevue/paginator';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import EmptyState from './EmptyState.vue';
|
||||
import TableSkeleton from './TableSkeleton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
sortBy: {
|
||||
type: String,
|
||||
default: 'createdAt'
|
||||
},
|
||||
sortOrder: {
|
||||
type: String,
|
||||
default: 'desc'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
selection: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
skeletonColumns: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['page-change', 'sort-change', 'search-change', 'update:selection']);
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
|
||||
const searchValue = ref('');
|
||||
const skeletonColumnCount = computed(() => Math.max(3, props.skeletonColumns || 5));
|
||||
const skeletonRowCount = computed(() => Math.min(Math.max(props.limit || 20, 5), 12));
|
||||
|
||||
const onSelectionChange = (value) => {
|
||||
emit('update:selection', value || []);
|
||||
};
|
||||
|
||||
let searchTimer = null;
|
||||
const onSearchInput = () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
emit('search-change', searchValue.value);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const onSort = (event) => {
|
||||
emit('sort-change', {
|
||||
sortBy: event.sortField,
|
||||
sortOrder: event.sortOrder === 1 ? 'asc' : 'desc'
|
||||
});
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
emit('page-change', {
|
||||
page: event.page + 1,
|
||||
limit: event.rows
|
||||
});
|
||||
};
|
||||
|
||||
const firstItemIndex = computed(() => {
|
||||
if (props.totalCount === 0) return 0;
|
||||
return (props.page - 1) * props.limit + 1;
|
||||
});
|
||||
|
||||
const lastItemIndex = computed(() => {
|
||||
return Math.min(props.page * props.limit, props.totalCount);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (props.selectable && props.selection?.length) {
|
||||
const ids = new Set(props.items.map((item) => String(item._id || item.id)));
|
||||
const next = props.selection.filter((item) => ids.has(String(item._id || item.id)));
|
||||
if (next.length !== props.selection.length) {
|
||||
emit('update:selection', next);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-table-wrapper {
|
||||
background-color: var(--surface-card);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user