2026-05-18 15:21:01 +00:00
|
|
|
<style scoped>
|
|
|
|
|
.filters-section {
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<template>
|
|
|
|
|
<div class="card shadow-sm h-100 reports-list">
|
|
|
|
|
<div class="card-header bg-light pane-header">
|
|
|
|
|
<div class="filters-section">
|
|
|
|
|
<slot
|
|
|
|
|
:active-filters="activeFilters"
|
|
|
|
|
:apply-filter="applyFilter"
|
|
|
|
|
:clear-filters="clearFilters"
|
|
|
|
|
name="filters"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body scroll-pane">
|
|
|
|
|
<div class="list-group list-group-flush">
|
|
|
|
|
<div class="loading list-group-item" v-if="loading || items == null">
|
|
|
|
|
Loading...
|
|
|
|
|
</div>
|
|
|
|
|
<div class="list-group-item" v-else-if="items.length == 0">
|
|
|
|
|
No items
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-else-if="items.length == 0"
|
|
|
|
|
class="list-group-item text-center text-muted p-4"
|
|
|
|
|
>
|
|
|
|
|
<i class="bi bi-inbox fs-1"></i>
|
|
|
|
|
<p class="mt-2">No items match the current filters</p>
|
|
|
|
|
<button class="btn btn-sm btn-outline-primary" @click="clearFilters">
|
|
|
|
|
Reset Filters
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else v-for="item in filteredItems" :key="item.id">
|
2026-05-18 15:40:02 +00:00
|
|
|
<slot
|
|
|
|
|
name="item"
|
|
|
|
|
:item="item"
|
|
|
|
|
:index="item.id"
|
|
|
|
|
:is-selected="isSelected(item)"
|
|
|
|
|
:toggle-selection="() => toggleSelection(item)"
|
|
|
|
|
></slot>
|
2026-05-18 15:21:01 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<script setup generic="T extends Record<string, any>" lang="ts">
|
|
|
|
|
import { ref, computed } from "vue";
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
items: T[];
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
itemKey?: keyof T | ((item: T) => string | number);
|
2026-05-18 15:40:02 +00:00
|
|
|
selectionMode?: "single" | "multiple" | "none";
|
2026-05-18 15:21:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
loading: false,
|
|
|
|
|
itemKey: "id",
|
2026-05-18 15:40:02 +00:00
|
|
|
selectionMode: "none",
|
2026-05-18 15:21:01 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
defineSlots<{
|
|
|
|
|
filters(props: {
|
|
|
|
|
applyFilter: (filterFn: (item: T) => boolean) => void;
|
|
|
|
|
clearFilters: () => void;
|
|
|
|
|
activeFilters: number;
|
|
|
|
|
}): any;
|
2026-05-18 15:40:02 +00:00
|
|
|
item(props: {
|
|
|
|
|
item: T;
|
|
|
|
|
index: string | number;
|
|
|
|
|
isSelected: boolean;
|
|
|
|
|
toggleSelection: () => void;
|
|
|
|
|
}): any;
|
2026-05-18 15:21:01 +00:00
|
|
|
footer(props: { total: number }): any;
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
// State
|
2026-05-18 15:40:02 +00:00
|
|
|
const emit = defineEmits<{
|
|
|
|
|
"update:selectedItems": [items: T[]];
|
|
|
|
|
"selection-change": [items: T[]];
|
|
|
|
|
}>();
|
2026-05-18 15:21:01 +00:00
|
|
|
const filterFunctions = ref<Array<(item: T) => boolean>>([]);
|
2026-05-18 15:40:02 +00:00
|
|
|
const selectedItemKeys = ref<Set<string | number>>(new Set());
|
2026-05-18 15:21:01 +00:00
|
|
|
|
|
|
|
|
// Computed
|
|
|
|
|
const filteredItems = computed(() => {
|
|
|
|
|
if (filterFunctions.value.length === 0) {
|
|
|
|
|
return props.items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return props.items.filter((item) =>
|
|
|
|
|
filterFunctions.value.every((fn) => fn(item)),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
const activeFilters = computed(() => filterFunctions.value.length);
|
2026-05-18 15:40:02 +00:00
|
|
|
const selectedItems = computed(() => {
|
|
|
|
|
return props.items.filter((item) =>
|
|
|
|
|
selectedItemKeys.value.has(getItemKey(item)),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-18 15:21:01 +00:00
|
|
|
|
|
|
|
|
// Methods
|
|
|
|
|
const applyFilter = (filterFn: (item: T) => boolean) => {
|
|
|
|
|
// Add or replace filter (you can modify this logic as needed)
|
|
|
|
|
filterFunctions.value.push(filterFn);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearFilters = () => {
|
|
|
|
|
filterFunctions.value = [];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getItemKey = (item: T): string | number => {
|
|
|
|
|
if (typeof props.itemKey === "function") {
|
|
|
|
|
return props.itemKey(item);
|
|
|
|
|
}
|
|
|
|
|
return item[props.itemKey] as string | number;
|
|
|
|
|
};
|
2026-05-18 15:40:02 +00:00
|
|
|
|
|
|
|
|
const isSelected = (item: T): boolean => {
|
|
|
|
|
return selectedItemKeys.value.has(getItemKey(item));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleSelection = (item: T) => {
|
|
|
|
|
if (props.selectionMode === "none") return;
|
|
|
|
|
|
|
|
|
|
const key = getItemKey(item);
|
|
|
|
|
|
|
|
|
|
if (props.selectionMode === "single") {
|
|
|
|
|
if (selectedItemKeys.value.has(key)) {
|
|
|
|
|
selectedItemKeys.value.clear();
|
|
|
|
|
} else {
|
|
|
|
|
selectedItemKeys.value.clear();
|
|
|
|
|
selectedItemKeys.value.add(key);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (selectedItemKeys.value.has(key)) {
|
|
|
|
|
selectedItemKeys.value.delete(key);
|
|
|
|
|
} else {
|
|
|
|
|
selectedItemKeys.value.add(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit("update:selectedItems", selectedItems.value);
|
|
|
|
|
emit("selection-change", selectedItems.value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearSelection = () => {
|
|
|
|
|
selectedItemKeys.value.clear();
|
|
|
|
|
emit("update:selectedItems", []);
|
|
|
|
|
emit("selection-change", []);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
clearSelection,
|
|
|
|
|
selectedItems,
|
|
|
|
|
});
|
2026-05-18 15:21:01 +00:00
|
|
|
</script>
|