feat: add loading state and enhance media thumbnail functionality in data tables for improved user experience
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-07 22:41:55 +07:00
parent f105d2cb3f
commit 765e74d8a9
6 changed files with 50 additions and 8 deletions

View File

@ -37,11 +37,13 @@ const props = withDefaults(
paginationDisplayedCount?: number;
paginationItemLabel?: string;
getRowClassName?: (row: TData, index: number) => string | undefined;
loading?: boolean;
}>(),
{
searchPlaceholder: 'Ketikkan sesuatu...',
showRowNumber: true,
paginationItemLabel: 'data',
loading: false,
}
);
@ -147,7 +149,15 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
@filters-reset="emit('filters-reset')" />
<div class="min-w-0 rounded-md border">
<div class="relative min-w-0 rounded-md border">
<!-- Loading Overlay -->
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-[1px] transition-all duration-300">
<div class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
<span class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span class="text-sm font-medium text-muted-foreground select-none">Memuat data...</span>
</div>
</div>
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">

View File

@ -13,6 +13,7 @@ const props = defineProps<{
items: MediaItem[];
maxVisible?: number;
title?: string;
allUrls?: string[];
}>();
const previewOpen = ref(false);
@ -119,5 +120,5 @@ function openGallery() {
</DialogContent>
</Dialog>
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="items.map(item => item.url)" :title="title" @close="onPreviewClose" />
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" @close="onPreviewClose" />
</template>

View File

@ -9,6 +9,7 @@ type UseDataTableQueryOptions = {
initial: DataTableQuery;
filterKeys?: string[];
debounceMs?: number;
only?: string[];
};
function buildParams(query: DataTableQuery, filterKeys: string[]): Record<string, string | undefined> {
@ -28,12 +29,21 @@ function buildParams(query: DataTableQuery, filterKeys: string[]): Record<string
export function useDataTableQuery(options: UseDataTableQueryOptions) {
const filterKeys = options.filterKeys ?? [];
const query = ref<DataTableQuery>({ ...options.initial });
const isLoading = ref(false);
const reload = useDebounceFn(() => {
router.get(options.url, buildParams(query.value, filterKeys), {
preserveState: true,
replace: true,
preserveScroll: true,
showProgress: false,
only: options.only,
onBefore: () => {
isLoading.value = true;
},
onFinish: () => {
isLoading.value = false;
},
});
}, options.debounceMs ?? 300);
@ -67,6 +77,7 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
return {
query,
isLoading,
setSearch,
setSort,
setFilter,

View File

@ -55,6 +55,10 @@ function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
function allVariantImageUrls(product: ProductListItem): string[] {
return product.variants.flatMap((variant) => (variant.images ?? []).map((img) => img.url));
}
const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null);
@ -148,7 +152,7 @@ function openVerificationDetail(requestId: number | undefined) {
{{ variant.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" />
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" />
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock_formatted }}

View File

@ -32,7 +32,7 @@ const props = defineProps<{
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { query, setSearch, setFilter, resetFilters, syncFromServer } =
const { query, isLoading, setSearch, setFilter, resetFilters, syncFromServer } =
useDataTableQuery({
url: index.url(),
initial: { ...props.filters },
@ -129,6 +129,7 @@ watch(
:pagination-links="rawMaterials.links"
:filter-defs="filterDefs"
:filter-values="filterValues"
:loading="isLoading"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>

View File

@ -32,6 +32,7 @@ const props = defineProps<{
paginationLinks?: DataTablePaginationLink[];
filterDefs?: DataTableFilterDef[];
filterValues?: Record<string, string>;
loading?: boolean;
}>();
const search = defineModel<string>('search', { default: '' });
@ -48,6 +49,10 @@ function rowNumber(index: number): number {
return groupedTableRowNumber(props.firstItem, index);
}
function allVariantImageUrls(material: RawMaterialListItem): string[] {
return material.prices.flatMap((price) => (price.images ?? []).map((img) => img.url));
}
const verificationModalOpen = ref(false);
const selectedRequestId = ref<number | null>(null);
@ -64,8 +69,17 @@ function openVerificationDetail(requestId: number | undefined) {
<DataTableToolbar v-model:search="search" :filter-defs="filterDefs" :filter-values="filterValues"
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
<div v-if="materials.length" class="space-y-4">
<div v-for="(material, index) in materials" :key="material.id" class="overflow-hidden rounded-md border">
<div class="relative min-h-[100px]">
<!-- Loading Overlay -->
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-[1px] transition-all duration-300">
<div class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
<span class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span class="text-sm font-medium text-muted-foreground select-none">Memuat data...</span>
</div>
</div>
<div v-if="materials.length" class="space-y-4">
<div v-for="(material, index) in materials" :key="material.id" class="overflow-hidden rounded-md border">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
@ -130,7 +144,7 @@ function openVerificationDetail(requestId: number | undefined) {
{{ price.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" />
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" :all-urls="allVariantImageUrls(material)" />
</TableCell>
<TableCell class="tabular-nums">
{{ price.stock_formatted }}
@ -144,7 +158,8 @@ function openVerificationDetail(requestId: number | undefined) {
</div>
</div>
<DataTableEmpty v-else />
<DataTableEmpty v-else />
</div>
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />