feat: add loading state and enhance media thumbnail functionality in data tables for improved user experience
This commit is contained in:
parent
f105d2cb3f
commit
765e74d8a9
@ -37,11 +37,13 @@ const props = withDefaults(
|
|||||||
paginationDisplayedCount?: number;
|
paginationDisplayedCount?: number;
|
||||||
paginationItemLabel?: string;
|
paginationItemLabel?: string;
|
||||||
getRowClassName?: (row: TData, index: number) => string | undefined;
|
getRowClassName?: (row: TData, index: number) => string | undefined;
|
||||||
|
loading?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
searchPlaceholder: 'Ketikkan sesuatu...',
|
searchPlaceholder: 'Ketikkan sesuatu...',
|
||||||
showRowNumber: true,
|
showRowNumber: true,
|
||||||
paginationItemLabel: 'data',
|
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)"
|
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
|
||||||
@filters-reset="emit('filters-reset')" />
|
@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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const props = defineProps<{
|
|||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
maxVisible?: number;
|
maxVisible?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
allUrls?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
@ -119,5 +120,5 @@ function openGallery() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</template>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ type UseDataTableQueryOptions = {
|
|||||||
initial: DataTableQuery;
|
initial: DataTableQuery;
|
||||||
filterKeys?: string[];
|
filterKeys?: string[];
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
|
only?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildParams(query: DataTableQuery, filterKeys: string[]): Record<string, string | undefined> {
|
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) {
|
export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
||||||
const filterKeys = options.filterKeys ?? [];
|
const filterKeys = options.filterKeys ?? [];
|
||||||
const query = ref<DataTableQuery>({ ...options.initial });
|
const query = ref<DataTableQuery>({ ...options.initial });
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
const reload = useDebounceFn(() => {
|
const reload = useDebounceFn(() => {
|
||||||
router.get(options.url, buildParams(query.value, filterKeys), {
|
router.get(options.url, buildParams(query.value, filterKeys), {
|
||||||
preserveState: true,
|
preserveState: true,
|
||||||
replace: true,
|
replace: true,
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
|
showProgress: false,
|
||||||
|
only: options.only,
|
||||||
|
onBefore: () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, options.debounceMs ?? 300);
|
}, options.debounceMs ?? 300);
|
||||||
|
|
||||||
@ -67,6 +77,7 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
|
isLoading,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSort,
|
setSort,
|
||||||
setFilter,
|
setFilter,
|
||||||
|
|||||||
@ -55,6 +55,10 @@ function rowNumber(index: number): number {
|
|||||||
return groupedTableRowNumber(props.firstItem, index);
|
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 verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
const selectedRequestId = ref<number | null>(null);
|
||||||
|
|
||||||
@ -148,7 +152,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ variant.stock_formatted }}
|
{{ variant.stock_formatted }}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const props = defineProps<{
|
|||||||
const { can } = useCan();
|
const { can } = useCan();
|
||||||
const search = ref(props.filters.search ?? '');
|
const search = ref(props.filters.search ?? '');
|
||||||
|
|
||||||
const { query, setSearch, setFilter, resetFilters, syncFromServer } =
|
const { query, isLoading, setSearch, setFilter, resetFilters, syncFromServer } =
|
||||||
useDataTableQuery({
|
useDataTableQuery({
|
||||||
url: index.url(),
|
url: index.url(),
|
||||||
initial: { ...props.filters },
|
initial: { ...props.filters },
|
||||||
@ -129,6 +129,7 @@ watch(
|
|||||||
:pagination-links="rawMaterials.links"
|
:pagination-links="rawMaterials.links"
|
||||||
:filter-defs="filterDefs"
|
:filter-defs="filterDefs"
|
||||||
:filter-values="filterValues"
|
:filter-values="filterValues"
|
||||||
|
:loading="isLoading"
|
||||||
@filter-change="setFilter"
|
@filter-change="setFilter"
|
||||||
@filters-reset="resetFilters"
|
@filters-reset="resetFilters"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -32,6 +32,7 @@ const props = defineProps<{
|
|||||||
paginationLinks?: DataTablePaginationLink[];
|
paginationLinks?: DataTablePaginationLink[];
|
||||||
filterDefs?: DataTableFilterDef[];
|
filterDefs?: DataTableFilterDef[];
|
||||||
filterValues?: Record<string, string>;
|
filterValues?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const search = defineModel<string>('search', { default: '' });
|
const search = defineModel<string>('search', { default: '' });
|
||||||
@ -48,6 +49,10 @@ function rowNumber(index: number): number {
|
|||||||
return groupedTableRowNumber(props.firstItem, index);
|
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 verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
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"
|
<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')" />
|
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
||||||
|
|
||||||
<div v-if="materials.length" class="space-y-4">
|
<div class="relative min-h-[100px]">
|
||||||
<div v-for="(material, index) in materials" :key="material.id" class="overflow-hidden 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>
|
||||||
|
|
||||||
|
<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
|
<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">
|
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">
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
@ -130,7 +144,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ price.variant }}
|
{{ price.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ price.stock_formatted }}
|
{{ price.stock_formatted }}
|
||||||
@ -144,7 +158,8 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTableEmpty v-else />
|
<DataTableEmpty v-else />
|
||||||
|
</div>
|
||||||
|
|
||||||
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
|
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user