refactor(bags): replace dual-section bag list with single paginated table

Remove the open-bags card grid and closed-bags history table in favour
of a unified table showing all bags with status badges and actions.

- Simplify load_bag_page to single BagFilter::all() query
- Remove open_bags field from BagPageData and BagListTemplate
- Add Status column with Open/Closed badges and remaining weight
- Add Close and Delete action buttons per row
This commit is contained in:
Jon Seager 2026-02-04 20:53:12 +00:00
parent 4bdfc661f9
commit 2eca0ae3c6
No known key found for this signature in database
4 changed files with 93 additions and 78 deletions

View file

@ -22,7 +22,6 @@ const BAG_PAGE_PATH: &str = "/data?type=bags";
const BAG_FRAGMENT_PATH: &str = "/data?type=bags#bag-list"; const BAG_FRAGMENT_PATH: &str = "/data?type=bags#bag-list";
pub(super) struct BagPageData { pub(super) struct BagPageData {
pub(super) open_bags: Vec<BagView>,
pub(super) bags: Paginated<BagView>, pub(super) bags: Paginated<BagView>,
pub(super) navigator: ListNavigator<BagSortKey>, pub(super) navigator: ListNavigator<BagSortKey>,
} }
@ -33,21 +32,9 @@ pub(super) async fn load_bag_page(
request: ListRequest<BagSortKey>, request: ListRequest<BagSortKey>,
search: Option<&str>, search: Option<&str>,
) -> Result<BagPageData, AppError> { ) -> Result<BagPageData, AppError> {
let open_request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
let open_page = state
.bag_repo
.list(BagFilter::open(), &open_request, None)
.await
.map_err(AppError::from)?;
let open_bags_view = open_page
.items
.into_iter()
.map(BagView::from_domain)
.collect();
let page = state let page = state
.bag_repo .bag_repo
.list(BagFilter::closed(), &request, search) .list(BagFilter::all(), &request, search)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
@ -60,11 +47,7 @@ pub(super) async fn load_bag_page(
search.map(String::from), search.map(String::from),
); );
Ok(BagPageData { Ok(BagPageData { bags, navigator })
open_bags: open_bags_view,
bags,
navigator,
})
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query))]
@ -295,15 +278,10 @@ async fn render_bag_list_fragment(
search: Option<String>, search: Option<String>,
is_authenticated: bool, is_authenticated: bool,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let BagPageData { let BagPageData { bags, navigator } = load_bag_page(&state, request, search.as_deref()).await?;
open_bags,
bags,
navigator,
} = load_bag_page(&state, request, search.as_deref()).await?;
let template = BagListTemplate { let template = BagListTemplate {
is_authenticated, is_authenticated,
open_bags,
bags, bags,
navigator, navigator,
}; };

View file

@ -198,7 +198,6 @@ async fn render_bags(
render_list( render_list(
BagListTemplate { BagListTemplate {
is_authenticated, is_authenticated,
open_bags: data.open_bags,
bags: data.bags, bags: data.bags,
navigator: data.navigator, navigator: data.navigator,
}, },

View file

@ -54,7 +54,6 @@ pub struct TimelineChunkTemplate {
#[template(path = "partials/bag_list.html")] #[template(path = "partials/bag_list.html")]
pub struct BagListTemplate { pub struct BagListTemplate {
pub is_authenticated: bool, pub is_authenticated: bool,
pub open_bags: Vec<BagView>,
pub bags: Paginated<BagView>, pub bags: Paginated<BagView>,
pub navigator: ListNavigator<BagSortKey>, pub navigator: ListNavigator<BagSortKey>,
} }

View file

@ -1,25 +1,20 @@
{% import "partials/table.html" as table %} {% import "partials/table.html" as table %}
{% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %}
<div id="bag-list" class="mt-6 space-y-8" data-star-scope="bags"> <div id="bag-list" class="mt-6" data-star-scope="bags">
{% if !open_bags.is_empty() %} {% if bags.items.is_empty() && !navigator.has_search() %}
<section> <div
<h2 class="text-lg font-semibold text-amber-700 mb-3">Open Bags</h2> class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> >
{% for bag in open_bags %} <p class="text-center">
{% call bag_card::card(bag, is_authenticated) %} No bags recorded yet. Use the form above to add your first bag.
{% endfor %} </p>
</div> </div>
</section> {% else %}
{% endif %} <section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
<section>
<h2 class="text-2xl font-bold mb-4 text-stone-800">History</h2>
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
{% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %} {% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %}
> >
{% call table::search_header(navigator, "#bag-list") %} {% call table::search_header(navigator, "#bag-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
@ -28,7 +23,11 @@
{% call table::sortable_header("Roaster", "roaster", navigator, "#bag-list") %} {% call table::sortable_header("Roaster", "roaster", navigator, "#bag-list") %}
{% call table::sortable_header("Roast", "roast", navigator, "#bag-list") %} {% call table::sortable_header("Roast", "roast", navigator, "#bag-list") %}
<th scope="col" class="px-4 py-3">Weight</th> <th scope="col" class="px-4 py-3">Weight</th>
<th scope="col" class="px-4 py-3">Status</th>
{% call table::sortable_header("Finished", "finished-at", navigator, "#bag-list") %} {% call table::sortable_header("Finished", "finished-at", navigator, "#bag-list") %}
{% if is_authenticated %}
<th scope="col" class="px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y divide-amber-200/70">
@ -42,16 +41,56 @@
</td> </td>
<td data-label="Roast" class="px-4 py-3 whitespace-nowrap">{{ bag.roast_name }}</td> <td data-label="Roast" class="px-4 py-3 whitespace-nowrap">{{ bag.roast_name }}</td>
<td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td> <td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td>
<td data-label="Status" class="px-4 py-3 whitespace-nowrap">
{% if bag.closed %}
<span class="inline-flex rounded-full bg-stone-100 px-2 py-1 text-xs font-medium text-stone-600">Closed</span>
{% else %}
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Open</span>
<div class="hidden md:block text-xs text-stone-500">{{ bag.remaining }}g left</div>
{% endif %}
</td>
{% if !bag.closed %}
<td data-label="Remaining" class="px-4 py-3 whitespace-nowrap md:hidden">{{ bag.remaining }}g left</td>
{% endif %}
<td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td> <td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td>
{% if is_authenticated %}
<td data-label="" class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-1">
{% if !bag.closed %}
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
title="Close bag"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
>
<span class="sr-only">Close bag</span>
{% call icons::x_circle("h-4 w-4") %}
</button>
{% endif %}
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
title="Delete bag"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
</div>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if bags.items.is_empty() %}
<div class="p-8 text-center text-stone-500">No bags match your search.</div>
{% endif %}
{% call table::pagination_header(bags, navigator, "#bag-list") %} {% call table::pagination_header(bags, navigator, "#bag-list") %}
{% if bags.has_next() %} {% if bags.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div> <div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
{% endif %} {% endif %}
</div>
</section> </section>
{% endif %}
</div> </div>