feat: add loading screens, skeleton states, and table loaders across all list pages
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-19 06:02:20 +03:30
parent 859a86f1a5
commit b6fde3e541
10 changed files with 298 additions and 16 deletions
+125
View File
@@ -0,0 +1,125 @@
<script setup>
defineProps({
type: {
type: String,
default: 'table' // 'table' | 'cards' | 'split' | 'spinner'
},
rows: {
type: Number,
default: 6
},
hasImage: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: false
},
message: {
type: String,
default: 'در حال بارگذاری اطلاعات...'
}
});
</script>
<template>
<div class="list-loading-container w-full animate-pulse">
<!-- Optional Top Header Shimmer -->
<div v-if="showHeader" class="flex flex-wrap items-center justify-between gap-4 mb-4">
<Skeleton width="12rem" height="2rem" class="rounded-xl" />
<Skeleton width="16rem" height="2.5rem" class="rounded-xl" />
</div>
<!-- ── TABLE SKELETON ── -->
<div
v-if="type === 'table'"
class="rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-0 dark:bg-surface-900 shadow-sm"
>
<!-- Table Header Bar -->
<div class="p-4 bg-surface-50 dark:bg-surface-800/60 border-b border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
<Skeleton width="10rem" height="1.5rem" class="rounded-lg" />
</div>
<Skeleton width="14rem" height="2.25rem" class="rounded-xl" />
</div>
<!-- Table Rows -->
<div class="divide-y divide-surface-100 dark:divide-surface-800">
<div v-for="r in rows" :key="r" class="p-4 flex items-center gap-4">
<Skeleton width="1.75rem" height="1.75rem" class="rounded-md shrink-0" />
<Skeleton v-if="hasImage" width="3.5rem" height="3.5rem" class="rounded-xl shrink-0" />
<div class="flex-1 grid grid-cols-2 sm:grid-cols-4 gap-4 items-center">
<Skeleton width="75%" height="1.25rem" class="rounded-md" />
<Skeleton width="55%" height="1.25rem" class="rounded-md" />
<Skeleton width="65%" height="1.25rem" class="rounded-md hidden sm:block" />
<Skeleton width="45%" height="1.25rem" class="rounded-md hidden sm:block" />
</div>
<div class="flex items-center gap-2 shrink-0">
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
<Skeleton width="2.25rem" height="2.25rem" class="rounded-full" />
</div>
</div>
</div>
</div>
<!-- ── CARDS GRID SKELETON ── -->
<div v-else-if="type === 'cards'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="c in rows"
:key="c"
class="card p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3"
>
<Skeleton width="100%" height="12rem" class="rounded-xl" />
<div class="flex items-center justify-between gap-2 mt-1">
<Skeleton width="60%" height="1.5rem" class="rounded-md" />
<Skeleton width="25%" height="1.25rem" class="rounded-full" />
</div>
<Skeleton width="40%" height="1rem" class="rounded-md" />
<div class="flex items-center justify-between pt-3 border-t border-surface-100 dark:border-surface-800 mt-auto">
<Skeleton width="30%" height="1.25rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
<!-- ── SPLIT VIEW SKELETON (Cards + Map Placeholder) ── -->
<div v-else-if="type === 'split'" class="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[750px]">
<!-- Left Cards list skeleton -->
<div class="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div class="flex items-center justify-between mb-2">
<Skeleton width="10rem" height="1.5rem" class="rounded-md" />
<Skeleton width="6rem" height="1.25rem" class="rounded-md" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3"
>
<Skeleton width="100%" height="10rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
<div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Skeleton width="35%" height="1rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
</div>
<!-- Right Map skeleton -->
<div class="lg:col-span-5 h-full rounded-2xl border border-surface-200 dark:border-surface-700 overflow-hidden bg-surface-100 dark:bg-surface-800 flex flex-col items-center justify-center p-8 text-center">
<i class="pi pi-map text-5xl text-surface-400 dark:text-surface-600 mb-4 animate-bounce"></i>
<Skeleton width="12rem" height="1.5rem" class="rounded-md mb-2" />
<Skeleton width="8rem" height="1rem" class="rounded-md" />
</div>
</div>
<!-- ── SPINNER / OVERLAY SKELETON ── -->
<div v-else class="flex flex-col items-center justify-center py-16 gap-4">
<ProgressSpinner style="width: 48px; height: 48px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500 dark:text-surface-400">{{ message }}</span>
</div>
</div>
</template>
+30 -1
View File
@@ -5,8 +5,22 @@ import { CategoryService } from '@/service/CategoryService';
import { useToast } from 'primevue/usetoast';
const toast = useToast();
import axios from 'axios';
const dataLoading = ref(true);
const loadData = async () => {
dataLoading.value = true;
try {
categories.value = await CategoryService.getAllCategories();
} catch (err) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در دریافت اطلاعات', life: 3000 });
} finally {
dataLoading.value = false;
}
};
onMounted(() => {
CategoryService.getAllCategories().then((data) => (categories.value = data));
loadData();
});
const dt = ref();
@@ -21,6 +35,7 @@ const filters = ref({
});
const submitted = ref(false);
function openNew() {
category.value = {};
submitted.value = false;
@@ -115,6 +130,7 @@ function deleteSelectedCategories() {
ref="dt"
v-model:selection="selectedCategories"
:value="categories"
:loading="dataLoading"
dataKey="id"
:paginator="true"
:rows="20"
@@ -123,9 +139,22 @@ function deleteSelectedCategories() {
:rowsPerPageOptions="[20, 40, 80]"
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} دسته‌بندی"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری دسته‌بندی‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ دسته‌بندی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0">مدیریت دسته بندی ها</h4>
<IconField>
<InputIcon>
<i class="pi pi-search" />
+27 -3
View File
@@ -12,11 +12,23 @@ const selectedFeature = ref();
const submitted = ref(false);
const feature = ref({});
const categories = ref([]);
const featureDialog = ref(false);
const dataLoading = ref(true);
onMounted(async () => {
categories.value = await CategoryService.getAllCategories();
dataLoading.value = true;
try {
categories.value = await CategoryService.getAllCategories();
if (categories.value.length > 0 && !selectedTab.value) {
selectedTab.value = categories.value[0]._id;
}
} catch (e) {
console.error(e);
} finally {
dataLoading.value = false;
}
});
const featureType = [
{
value: 'string',
@@ -77,12 +89,24 @@ const saveFeature = async () => {
<template>
<div className="card">
<div class="font-semibold text-xl mb-4">ویژگی دسته بندی ها</div>
<Tabs :value="selectedTab" :pt="{}">
<div v-if="dataLoading" class="space-y-4">
<div class="flex gap-2">
<Skeleton v-for="i in 4" :key="i" width="8rem" height="2.5rem" class="rounded-xl" />
</div>
<div class="w-1/3 my-4 space-y-2">
<Skeleton width="100%" height="15rem" class="rounded-xl" />
<Skeleton width="6rem" height="2.5rem" class="rounded-lg" />
</div>
</div>
<Tabs v-else :value="selectedTab" :pt="{}">
<TabList>
<Tab v-for="category in categories" :key="category._id" :value="category._id">
{{ category.nativeName }}
</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="category in categories" :key="category._id + '_content'" :value="category._id">
<p>لیست ویژگی های {{ category.nativeName }}</p>
+41 -2
View File
@@ -124,7 +124,10 @@ const onPageChange = (event) => {
}
};
const dataLoading = ref(true);
const loadData = async () => {
dataLoading.value = true;
try {
if (searchMethod.value === null) {
await loadSearchSetting();
@@ -141,10 +144,13 @@ const loadData = async () => {
} catch (error) {
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات املاک', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -322,6 +328,7 @@ const onCardHover = (propertyId) => {
ref="dt"
v-model:selection="selectedProperties"
:value="properties"
:loading="dataLoading"
dataKey="_id"
:paginator="true"
:rows="per_page"
@@ -335,12 +342,25 @@ const onCardHover = (propertyId) => {
stripedRows
responsiveLayout="scroll"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اطلاعات املاک...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ ملکی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap gap-2 items-center justify-between">
<h4 class="m-0 text-lg font-bold">لیست املاک ({{ totalCount }} مورد)</h4>
</div>
</template>
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
<Column header="تصویر" style="width: 5rem">
<template #body="slotProps">
@@ -423,8 +443,25 @@ const onCardHover = (propertyId) => {
</span>
</div>
<!-- Loading Skeleton in Split View -->
<div v-if="dataLoading" class="cards-grid">
<div
v-for="i in 6"
:key="i"
class="p-4 rounded-2xl border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 shadow-sm flex flex-col gap-3 animate-pulse"
>
<Skeleton width="100%" height="11rem" class="rounded-xl" />
<Skeleton width="70%" height="1.25rem" class="rounded-md" />
<Skeleton width="45%" height="1rem" class="rounded-md" />
<div class="flex justify-between items-center pt-2 border-t border-surface-100 dark:border-surface-800">
<Skeleton width="35%" height="1rem" class="rounded-md" />
<Skeleton width="2rem" height="2rem" class="rounded-lg" />
</div>
</div>
</div>
<!-- Cards Grid -->
<div v-if="properties.length > 0" class="cards-grid">
<div v-else-if="properties.length > 0" class="cards-grid">
<div
v-for="prop in properties"
:key="prop._id || prop.id"
@@ -505,9 +542,11 @@ const onCardHover = (propertyId) => {
</div>
<div v-else class="p-8 text-center text-slate-500">
ملکی یافت نشد.
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>ملکی یافت نشد.</p>
</div>
<!-- Pagination -->
<Paginator
:rows="per_page"
+11 -2
View File
@@ -11,8 +11,9 @@ const confirm = useConfirm();
const roles = ref([]);
const allPermissions = ref([]);
const loading = ref(false);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedRole = ref(null);
@@ -253,14 +254,22 @@ const confirmDelete = (r, event) => {
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری نقش‌ها و دسترسی‌ها...</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<div v-if="!loading" class="empty-state">
<i class="pi pi-shield empty-icon"></i>
<p>هیچ نقشی تعریف نشده است</p>
<Button label="افزودن اولین نقش" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<!-- Native Name -->
<Column field="nativeName" header="نام فارسی نقش" style="min-width: 180px">
<template #body="{ data }">
@@ -121,7 +121,10 @@ const onPageChange = (event) => {
}
};
const dataLoading = ref(true);
const loadData = async () => {
dataLoading.value = true;
try {
if (searchMethod.value === null) {
await loadSearchSetting();
@@ -138,10 +141,13 @@ const loadData = async () => {
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
} finally {
dataLoading.value = false;
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -277,6 +283,7 @@ const toggle = (event, data) => {
ref="dt"
v-model:selection="selectedTransactions"
:value="transactions"
:loading="dataLoading"
dataKey="id"
:paginator="true"
:rows="per_page"
@@ -292,6 +299,19 @@ const toggle = (event, data) => {
filterDisplay="menu"
:globalFilterFields="['type', 'status', 'belongingName', 'createdAt', 'description']"
>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌ها...</span>
</div>
</template>
<template #empty>
<div v-if="!dataLoading" class="p-8 text-center text-surface-500">
<i class="pi pi-inbox text-4xl mb-2 text-surface-400"></i>
<p>هیچ تراکنشی یافت نشد.</p>
</div>
</template>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
+11 -2
View File
@@ -11,8 +11,9 @@ const confirm = useConfirm();
const users = ref([]);
const roles = ref([]);
const loading = ref(false);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedUser = ref(null);
@@ -266,14 +267,22 @@ const getInitials = (name, email) => {
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری کاربران...</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<div v-if="!loading" class="empty-state">
<i class="pi pi-user-minus empty-icon"></i>
<p>هیچ کاربری یافت نشد</p>
<Button label="افزودن اولین کاربر" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<!-- User Column -->
<Column header="کاربر" style="min-width: 200px">
<template #body="{ data }">
@@ -11,8 +11,9 @@ const toast = useToast();
const transactions = ref([]);
const totalCount = ref(0);
const loading = ref(false);
const loading = ref(true);
const page = ref(1);
const perPage = ref(30);
// Filters
@@ -196,13 +197,21 @@ const formatDate = (d) => {
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری تراکنش‌های انبار...</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<div v-if="!loading" class="empty-state">
<i class="pi pi-inbox"></i>
<p>هیچ تراکنشی یافت نشد</p>
</div>
</template>
<Column header="نوع" style="width:7%">
<template #body="{ data }">
<Tag
@@ -16,7 +16,8 @@ const totalCount = ref(0);
const page = ref(Number(route.query.page) || 1);
const perPage = ref(Number(route.query.per_page) || 20);
const searchPhrase = ref(route.query.search || '');
const loading = ref(false);
const loading = ref(true);
// Create/Edit dialog
const showDialog = ref(false);
@@ -217,14 +218,22 @@ const viewStock = (itemId) => {
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری اقلام انبار...</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<div v-if="!loading" class="empty-state">
<i class="pi pi-inbox empty-icon"></i>
<p>هیچ قلمی یافت نشد</p>
<Button label="افزودن اولین قلم" icon="pi pi-plus" @click="openCreateDialog" />
</div>
</template>
<Column field="code" header="کد" style="width:8%">
<template #body="{ data }">
<span class="code-badge" v-if="data.code">{{ data.code }}</span>
@@ -9,8 +9,9 @@ const toast = useToast();
const confirm = useConfirm();
const warehouses = ref([]);
const loading = ref(false);
const loading = ref(true);
const showDialog = ref(false);
const editMode = ref(false);
const submitting = ref(false);
const selectedWarehouse = ref(null);
@@ -143,14 +144,22 @@ const onSearch = () => {
</div>
</template>
<template #loading>
<div class="flex flex-col items-center justify-center p-8 gap-3">
<ProgressSpinner style="width: 42px; height: 42px" strokeWidth="4" />
<span class="text-sm font-medium text-surface-500">در حال بارگذاری انبارها...</span>
</div>
</template>
<template #empty>
<div class="empty-state">
<div v-if="!loading" class="empty-state">
<i class="pi pi-map empty-icon"></i>
<p>هیچ انباری تعریف نشده است</p>
<Button label="افزودن اولین انبار" icon="pi pi-plus" @click="openCreate" />
</div>
</template>
<Column field="code" header="کد" style="width:8%">
<template #body="{ data }">
<span v-if="data.code" class="code-badge">{{ data.code }}</span>