Refactor stock filters into a responsive sidebar rail
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-12 13:37:45 +02:00
parent 569ef1804b
commit 1d23279819
2 changed files with 374 additions and 154 deletions
+181 -145
View File
@@ -300,144 +300,158 @@ export function renderStockListPage() {
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<label class="form-label mb-0">Search stock</label>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click="clearFilters()">Reset</button>
</div>
<div class="row g-3">
<div class="col-12">
<input
class="form-control"
type="text"
x-model.debounce.250ms="filters.search"
placeholder="Search by item, description, location, or id"
/>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4 align-items-start" :class="overviewRowClass()">
<div class="col-12" :class="overviewColClass('expiration')">
<details
class="card border-0 shadow-sm overview-panel"
x-ref="expirationOverview"
@toggle="setOverviewOpen('expiration', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="row g-4 stock-workspace">
<aside class="col-12 col-xl-4 col-xxl-3 stock-filter-rail">
<div class="card border-0 shadow-sm stock-filter-hub">
<div class="card-body p-4">
<div class="d-flex flex-column gap-2 mb-3">
<div>
<h2 class="h5 mb-1">Expiration overview</h2>
<p class="text-body-secondary small mb-0">Tap to focus on one or more expiration states.</p>
<h2 class="h5 mb-1">Search and filters</h2>
<p class="text-body-secondary small mb-0">
Focus the stock list with one control panel.
</p>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllExpirationFilters()">Show all</button>
<div class="d-flex justify-content-between align-items-center">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click="clearFilters()">Reset all</button>
<div class="small text-body-secondary text-end">
<span class="fw-semibold text-body" x-text="visibleResultCount"></span>
<span x-text="viewMode === 'grouped' ? 'group(s) visible' : 'item(s) visible'"></span>
<span x-text="viewMode === 'grouped' ? 'group(s)' : 'item(s)'"></span>
</div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<div class="overview-list" :class="{ 'overview-list-split': isOnlyOverviewOpen('expiration') }">
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
<button
class="overview-option text-start"
type="button"
:class="legendClass(stateInfo.key)"
@click="toggleExpirationOverviewFilter(stateInfo.key)"
:aria-pressed="isExpirationFilterActive(stateInfo.key)"
>
<div class="d-flex justify-content-between align-items-start gap-3 mb-1">
<div class="fw-semibold" x-text="stateInfo.label"></div>
<div class="small fw-semibold" x-text="expirationCount(stateInfo.key)"></div>
</div>
<div class="small" x-text="stateInfo.description"></div>
</button>
</template>
<div class="mb-3">
<label class="form-label mb-1">Search stock</label>
<input
class="form-control"
type="text"
x-model.debounce.250ms="filters.search"
placeholder="Search by item, description, location, or id"
/>
</div>
</div>
</details>
</div>
<div class="col-12" :class="overviewColClass('location')">
<details
class="card border-0 shadow-sm overview-panel"
x-ref="locationOverview"
@toggle="setOverviewOpen('location', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-1">Location overview</h2>
<p class="text-body-secondary small mb-0">Tap locations to focus the list. Parent locations include their children.</p>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllLocations()">Show all</button>
<div class="small text-body-secondary text-end" x-text="selectedLocationSummary()"></div>
<div class="stock-filter-summary mb-3">
<div class="small text-body-secondary mb-2">Active scope</div>
<div class="d-grid gap-1">
<div class="small"><span class="fw-semibold">Expiration:</span> <span x-text="expirationFilterSummary()"></span></div>
<div class="small"><span class="fw-semibold">Location:</span> <span x-text="locationFilterSummary()"></span></div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<template x-if="isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations location-overview-columns">
<template x-for="(columnGroups, columnIndex) in balancedLocationOverviewColumns()" :key="columnIndex">
<div class="location-overview-column">
<template x-for="group in columnGroups" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
<div
class="d-grid gap-3 stock-filter-panels"
:class="{ 'stock-filter-panels-single-open': openOverviewCount() > 0 }"
>
<details
class="overview-panel stock-filter-panel-card"
x-ref="expirationOverview"
@toggle="setOverviewOpen('expiration', $event.target.open)"
>
<summary class="p-3 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h6 mb-1">Expiration overview</h3>
<p class="text-body-secondary small mb-0">Tap to focus expiration states.</p>
</div>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllExpirationFilters()">Show all</button>
</div>
</template>
</div>
</template>
<template x-if="!isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations">
<template x-for="group in locationOverviewGroups()" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
</summary>
<div class="pt-0 px-3 pb-3">
<div class="overview-list">
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
<button
class="overview-option overview-option-location text-start"
class="overview-option text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
:class="legendClass(stateInfo.key)"
@click="toggleExpirationOverviewFilter(stateInfo.key)"
:aria-pressed="isExpirationFilterActive(stateInfo.key)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
<div class="d-flex justify-content-between align-items-start gap-3 mb-1">
<div class="fw-semibold" x-text="stateInfo.label"></div>
<div class="small fw-semibold" x-text="expirationCount(stateInfo.key)"></div>
</div>
<div class="small" x-text="stateInfo.description"></div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</details>
</div>
</div>
</div>
</details>
<details
class="overview-panel stock-filter-panel-card"
x-ref="locationOverview"
@toggle="setOverviewOpen('location', $event.target.open)"
>
<summary class="p-3 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h6 mb-1">Location overview</h3>
<p class="text-body-secondary small mb-0">Parent locations include children.</p>
</div>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllLocations()">Show all</button>
</div>
</summary>
<div class="pt-0 px-3 pb-3">
<template x-if="isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations location-overview-columns">
<template x-for="(columnGroups, columnIndex) in balancedLocationOverviewColumns()" :key="columnIndex">
<div class="location-overview-column">
<template x-for="group in columnGroups" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<template x-if="!isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations">
<template x-for="group in locationOverviewGroups()" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</details>
</div>
</div>
</div>
</aside>
<div class="col-12 col-xl-8 col-xxl-9 stock-results-pane">
<template x-if="showInitialLoader()">
<div class="alert alert-secondary">Loading stock review...</div>
</template>
@@ -630,29 +644,28 @@ export function renderStockListPage() {
:data-group-id="String(group.id)"
@toggle="handleGroupedToggle($event)"
>
<summary class="card-body p-4 grouped-stock-summary">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
<summary class="card-body p-3 grouped-stock-summary">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-2 grouped-stock-summary-row">
<div>
<div class="fw-semibold fs-5" x-text="group.name"></div>
<div class="text-body-secondary small mb-2" x-text="group.description || 'No description'"></div>
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></div>
<div class="text-body-secondary small grouped-stock-summary-description" x-show="group.description" x-text="group.description"></div>
<div class="d-flex flex-wrap gap-3 small grouped-stock-summary-meta">
<span><span class="fw-semibold text-body" x-text="groupItemCount(group)"></span> item(s)</span>
<span><span class="text-body-secondary">Latest location:</span> <span class="fw-semibold text-body" x-text="locationLabel(group)"></span></span>
<span><span class="text-body-secondary">Latest quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
</div>
<a class="small text-decoration-none fw-semibold d-inline-block mt-2" :href="groupDetailHref(group)" x-show="groupDetailHref(group)">View item</a>
</div>
<div class="d-flex flex-column align-items-xl-end gap-2">
<span class="badge rounded-pill align-self-start align-self-xl-end" :class="badgeClass(group)" x-text="expirationFor(group).label"></span>
<div class="small text-body-secondary text-xl-end">
<div class="grouped-stock-summary-status">
<span class="badge rounded-pill" :class="badgeClass(group)" x-text="expirationFor(group).label"></span>
<div class="small text-body-secondary">
<span class="text-body-secondary">First expires:</span>
<span x-text="expirationFor(group).detail"></span>
</div>
<div class="small fw-semibold grouped-stock-toggle-label">Show items</div>
<div class="small fw-semibold grouped-stock-toggle-label">Items</div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<div class="card-body pt-0 px-4 pb-3">
<div class="grouped-stock-secondary-details mb-3">
<details class="grouped-stock-secondary-toggle">
<summary class="small fw-semibold">More dates and metadata</summary>
@@ -679,24 +692,28 @@ export function renderStockListPage() {
<div class="grouped-stock-items">
<template x-for="item in group.items" :key="item.id">
<div class="grouped-stock-item" :class="groupedItemClass(item)">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
<div>
<div class="fw-semibold" x-text="item.name"></div>
<div class="grouped-stock-item-row">
<div class="grouped-stock-item-main">
<div class="grouped-stock-item-title-line">
<div class="fw-semibold grouped-stock-item-name" x-text="item.name"></div>
<span class="font-monospace grouped-stock-item-id" x-text="shortId(item)"></span>
</div>
<div class="small text-body-secondary grouped-stock-item-subline">
<span x-text="locationLabel(item)"></span>
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
<span x-text="shortDescription(item.description)"></span>
</div>
<a class="small text-decoration-none fw-semibold" :href="detailHref(item)">View item</a>
</div>
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
<div class="small text-body-secondary grouped-stock-item-aux">
<span x-text="quantityLabel(item)"></span>
<span class="grouped-stock-date-pair">
<span x-text="formatDate(item.date)"></span>
<span class="grouped-stock-date-separator" aria-hidden="true">→</span>
<span x-text="formatDate(item.expire_date)"></span>
</span>
<span class="font-monospace" x-text="shortId(item)"></span>
</div>
<div class="grouped-stock-item-actions">
<a class="text-decoration-none fw-semibold grouped-stock-item-link" :href="detailHref(item)">Details</a>
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
</div>
</div>
@@ -716,13 +733,13 @@ export function renderStockListPage() {
</div>
</template>
<div class="mt-3 d-flex justify-content-end">
<div class="grouped-stock-close-row">
<button
class="btn btn-link p-0 text-decoration-none grouped-stock-close"
class="btn btn-link text-decoration-none grouped-stock-close"
type="button"
@click.prevent="closeGroupedCard($el.closest('details'))"
>
Close group
Collapse group
</button>
</div>
</div>
@@ -737,6 +754,8 @@ export function renderStockListPage() {
</template>
</div>
</template>
</div>
</div>
</section>
`;
}
@@ -1145,13 +1164,6 @@ export function stockListPageData(store) {
}
return Array.isArray(group.items) ? group.items.length : 0;
},
groupDetailHref(group) {
if (group?.uuid_b64) {
return this.detailHref(group);
}
const firstItem = Array.isArray(group?.items) ? group.items[0] : null;
return firstItem?.uuid_b64 ? this.detailHref(firstItem) : '';
},
isGroupedCardOpen(groupId) {
return Boolean(this.openGroupedCards[String(groupId)]);
},
@@ -1228,6 +1240,30 @@ export function stockListPageData(store) {
}
return unique;
},
expirationFilterSummary() {
const active = this.effectiveExpirationFilters();
if (!active.length) {
return 'All states';
}
if (active.length === 1) {
return this.expirationLegend.find((state) => state.key === active[0])?.label || '1 state';
}
return `${active.length} states selected`;
},
locationFilterSummary() {
const active = this.effectiveLocationFilters();
if (!active.length) {
return 'All locations';
}
if (active.length === 1) {
return this.locationMap[active[0]] || '1 location selected';
}
return `${active.length} locations selected`;
},
normalizedSearchTerm() {
return String(this.filters.search || '').trim().toLowerCase();
},