Add generic layout of listing cards

This should eventually by ported to other interfaces for consistency.
This commit is contained in:
Eli Ribble 2026-05-18 15:21:01 +00:00
parent 6db5186318
commit 83f76297b5
No known key found for this signature in database
5 changed files with 267 additions and 2 deletions

View file

@ -0,0 +1,57 @@
<style scoped lang="scss">
.report-card {
cursor: pointer;
transition: background-color 0.2s;
}
.report-card:hover {
background-color: $secondary;
}
.report-card.active {
background-color: $primary;
color: white;
}
.reports-list {
overflow-y: auto;
max-height: 100vh;
}
</style>
<template>
<!-- First row: icon, type badge, and time -->
<div
class="align-items-center justify-content-between list-group-item p-3 report-card"
:class="{ active: isSelected }"
>
<div class="row">
<div class="d-flex align-items-center">
<div class="col">
<i
class="bi fs-4 me-2"
:class="{ 'bi-envelope': contact.emails.length != 0 }"
/>
<i
class="bi fs-4 me-2"
:class="{ 'bi-phone': contact.phones.length != 0 }"
/>
</div>
<div class="col-6 text-end">
<small>{{ contact.name }}</small>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TimeRelative from "@/components/TimeRelative.vue";
import Tooltip from "@/components/Tooltip.vue";
import { formatAddress, formatDate } from "@/format";
import { Contact } from "@/type/api";
interface Props {
contact: Contact;
isSelected: boolean;
}
const props = defineProps<Props>();
</script>

View file

@ -1,3 +1,106 @@
<style scoped>
.user-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.user-filters input[type="text"],
.user-filters select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.user-filters button {
padding: 0.5rem 1rem;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.user-footer {
font-weight: 500;
color: #666;
}
</style>
<template>
<p>list</p>
<ListCard :items="items" :loading="loading">
<!-- Filters Slot -->
<template #filters="{ applyFilter, clearFilters, activeFilters }">
<div class="user-filters">
<input
v-model="searchTerm"
type="text"
placeholder="Search by name..."
@input="handleSearch(applyFilter)"
/>
<button
@click="handleClearFilters(clearFilters)"
v-if="activeFilters > 0"
>
Clear Filters ({{ activeFilters }})
</button>
</div>
</template>
<!-- Item Slot -->
<template #item="{ item }">
<ListCardContact
:contact="item"
@click="handleContactClick(item)"
:isSelected="false"
/>
</template>
</ListCard>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import ListCard from "@/components/layout/ListCard.vue";
import ListCardContact from "@/components/ListCardContact.vue";
import { Contact } from "@/type/api";
interface Props {
contacts: Contact[] | undefined;
loading: boolean;
}
const loading = ref(false);
const props = defineProps<Props>();
const searchTerm = ref("");
const roleFilter = ref("");
const activeOnly = ref(false);
// Filter handlers
const handleSearch = (
applyFilter: (fn: (contact: Contact) => boolean) => void,
) => {
applyFilter((contact: Contact) =>
contact.name.toLowerCase().includes(searchTerm.value.toLowerCase()),
);
};
const handleClearFilters = (clearFilters: () => void) => {
searchTerm.value = "";
roleFilter.value = "";
activeOnly.value = false;
clearFilters();
};
const handleContactClick = (contact: any) => {
console.log("Clicked contact:", contact);
};
const items = computed((): Contact[] => {
if (props.contacts) {
return props.contacts;
}
return [];
});
</script>

View file

@ -0,0 +1,98 @@
<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">
<slot name="item" :item="item" :index="item.id"></slot>
</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);
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
itemKey: "id",
});
defineSlots<{
filters(props: {
applyFilter: (filterFn: (item: T) => boolean) => void;
clearFilters: () => void;
activeFilters: number;
}): any;
item(props: { item: T; index: number }): any;
footer(props: { total: number }): any;
}>();
// State
const filterFunctions = ref<Array<(item: T) => boolean>>([]);
// 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);
// 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;
};
</script>

View file

@ -92,6 +92,9 @@ function createResourceStore<dto, full extends uriHaver>(
throw err;
}
}
function loadingAll(): boolean {
return !!_resourceFetchAll.value;
}
function loadingURI(uri: string): boolean {
return !!_resourceFetchByURI.value.get(uri);
}
@ -105,6 +108,7 @@ function createResourceStore<dto, full extends uriHaver>(
fetchAll,
fetchByID,
fetchByURI,
loadingAll,
loadingURI,
};
}

View file

@ -1,7 +1,10 @@
<template>
<ThreeColumn>
<template #left>
<ReviewContactColumnList />
<ReviewContactColumnList
:contacts="contacts"
:loading="storeResource.contact.loadingAll()"
/>
</template>
<template #center>
<ReviewContactColumnDetail />