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
+93 -57
View File
@@ -300,14 +300,28 @@ export function renderStockListPage() {
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<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 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 class="d-flex flex-column gap-2 mb-3">
<div>
<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="row g-3">
<div class="col-12">
<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)' : 'item(s)'"></span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label mb-1">Search stock</label>
<input
class="form-control"
type="text"
@@ -315,34 +329,35 @@ export function renderStockListPage() {
placeholder="Search by item, description, location, or id"
/>
</div>
</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>
<div class="row g-4 mb-4 align-items-start" :class="overviewRowClass()">
<div class="col-12" :class="overviewColClass('expiration')">
<div
class="d-grid gap-3 stock-filter-panels"
:class="{ 'stock-filter-panels-single-open': openOverviewCount() > 0 }"
>
<details
class="card border-0 shadow-sm overview-panel"
class="overview-panel stock-filter-panel-card"
x-ref="expirationOverview"
@toggle="setOverviewOpen('expiration', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<summary class="p-3 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-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>
<h3 class="h6 mb-1">Expiration overview</h3>
<p class="text-body-secondary small mb-0">Tap to focus expiration states.</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="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>
</div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<div class="overview-list" :class="{ 'overview-list-split': isOnlyOverviewOpen('expiration') }">
<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 text-start"
@@ -361,26 +376,22 @@ export function renderStockListPage() {
</div>
</div>
</details>
</div>
<div class="col-12" :class="overviewColClass('location')">
<details
class="card border-0 shadow-sm overview-panel"
class="overview-panel stock-filter-panel-card"
x-ref="locationOverview"
@toggle="setOverviewOpen('location', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<summary class="p-3 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>
<h3 class="h6 mb-1">Location overview</h3>
<p class="text-body-secondary small mb-0">Parent locations include 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>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<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">
@@ -437,7 +448,10 @@ export function renderStockListPage() {
</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();
},
+192 -8
View File
@@ -314,6 +314,54 @@ body {
color: var(--lonc-primary);
}
.stock-filter-hub {
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(245, 250, 255, 0.88)),
linear-gradient(135deg, rgba(93, 169, 255, 0.1), rgba(31, 75, 153, 0.06));
}
.stock-workspace {
align-items: flex-start;
}
.stock-results-pane {
min-width: 0;
}
.stock-filter-summary {
padding: 0.85rem 0.95rem;
border-radius: 0.9rem;
border: 1px dashed rgba(31, 75, 153, 0.24);
background: rgba(255, 255, 255, 0.7);
min-height: 100%;
}
.stock-filter-rail .overview-list-locations {
max-height: 18rem;
}
.stock-filter-rail .location-overview-columns {
grid-template-columns: 1fr;
}
@media (min-width: 768px) and (max-width: 1199.98px) {
.stock-filter-panels {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.stock-filter-panels.stock-filter-panels-single-open {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.stock-filter-rail .stock-filter-hub {
position: sticky;
top: 1rem;
}
}
.stock-view-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -383,6 +431,16 @@ body {
height: auto;
}
.stock-filter-panel-card {
border: 1px solid var(--lonc-border);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.84);
}
.stock-filter-panel-card[open] {
box-shadow: 0 10px 24px rgba(24, 42, 79, 0.08);
}
.overview-summary {
display: block;
cursor: pointer;
@@ -646,6 +704,19 @@ button.legend-card:focus-visible {
list-style: none;
}
.grouped-stock-summary-row {
min-height: 0;
}
.grouped-stock-summary-title {
font-size: 1.1rem;
line-height: 1.2;
}
.grouped-stock-summary-description {
margin: 0.12rem 0 0.35rem;
}
.grouped-stock-summary::-webkit-details-marker {
display: none;
}
@@ -655,6 +726,14 @@ button.legend-card:focus-visible {
row-gap: 0.35rem;
}
.grouped-stock-summary-status {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.4rem 0.75rem;
}
.grouped-stock-secondary-details {
border-top: 1px dashed rgba(31, 39, 64, 0.14);
padding-top: 0.75rem;
@@ -678,11 +757,13 @@ button.legend-card:focus-visible {
.grouped-stock-toggle-label {
color: var(--lonc-primary);
font-size: 0.8rem;
line-height: 1.1;
}
.grouped-stock-toggle-label::after {
content: 'Expand';
margin-left: 0.35rem;
margin-left: 0.25rem;
}
.grouped-stock-card[open] .grouped-stock-toggle-label::after {
@@ -709,14 +790,20 @@ button.legend-card:focus-visible {
border-left-color: #6c757d;
}
@media (min-width: 1200px) {
.grouped-stock-summary-status {
justify-content: flex-end;
}
}
.grouped-stock-items {
display: grid;
gap: 0.75rem;
gap: 0.55rem;
}
.grouped-stock-item {
display: block;
padding: 0.9rem 1rem;
padding: 0.65rem 0.8rem;
border-radius: 0.95rem;
border: 1px solid var(--lonc-border);
background: rgba(255, 255, 255, 0.72);
@@ -753,12 +840,56 @@ button.legend-card:focus-visible {
background: rgba(108, 117, 125, 0.08);
}
.grouped-stock-item-meta {
justify-content: flex-start;
.grouped-stock-item-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.75rem;
}
.grouped-stock-item-main {
min-width: 0;
}
.grouped-stock-item-title-line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
.grouped-stock-item-name {
font-size: 1rem;
line-height: 1.2;
}
.grouped-stock-item-id {
color: var(--lonc-muted);
font-size: 0.8rem;
}
.grouped-stock-item-aux {
display: grid;
gap: 0.2rem;
text-align: right;
justify-items: end;
white-space: nowrap;
}
.grouped-stock-item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.grouped-stock-item-link {
font-size: 0.8rem;
}
.grouped-stock-mark-gone {
align-self: center;
padding: 0.2rem 0.55rem;
line-height: 1.2;
white-space: nowrap;
}
@@ -784,9 +915,62 @@ button.legend-card:focus-visible {
font-weight: 700;
}
@media (min-width: 1200px) {
.grouped-stock-item-meta {
.grouped-stock-close-row {
display: flex;
justify-content: flex-end;
margin-top: 0.2rem;
padding-top: 0.2rem;
border-top: 1px dashed rgba(31, 39, 64, 0.12);
}
.grouped-stock-close {
padding: 0.1rem 0.2rem;
font-size: 0.78rem;
color: rgba(31, 39, 64, 0.82);
line-height: 1.1;
}
.grouped-stock-close:hover {
color: var(--lonc-primary-dark);
}
@media (max-width: 991.98px) {
.grouped-stock-item-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.grouped-stock-item-actions {
grid-column: 1 / -1;
justify-content: space-between;
padding-top: 0.15rem;
}
.grouped-stock-item-aux {
text-align: left;
justify-items: start;
}
}
@media (max-width: 575.98px) {
.grouped-stock-item {
padding: 0.6rem 0.7rem;
}
.grouped-stock-item-row {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.grouped-stock-item-aux {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
text-align: left;
}
.grouped-stock-item-title-line {
gap: 0.45rem;
}
}