app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit cdb4b18e136271bd37b058aed20a75281658e8c0
parent 5a8586cb55addfbac9c9acc24bc92062d979abd2
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 00:14:09 -0700

app: show localized trade workflow badges

- add workflow projections to buyer and seller order rows and details
- derive badge axes from reducer-aligned order status and economics
- render localized agreement, fulfillment, inventory, payment, and source badges
- update localization keys, source guards, and projection fixtures

Diffstat:
Mcrates/desktop/src/source_guards.rs | 13++++++-------
Mcrates/desktop/src/window.rs | 255+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/i18n/src/keys.rs | 6++++++
Mcrates/i18n/src/lib.rs | 10++++++++++
Mcrates/state/src/lib.rs | 26+++++++++++++++++++-------
Mcrates/store/src/repo/buyer.rs | 29+++++++++++++++++++----------
Mcrates/store/src/repo/orders.rs | 19++++++++++++++-----
Mcrates/view/src/lib.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mi18n/locales/en/messages.json | 6++++++
9 files changed, 410 insertions(+), 114 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -393,7 +393,6 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailTitle", "AppTextKey::PersonalOrdersDetailEmptyBody", "AppTextKey::PersonalOrdersDetailFarmLabel", - "AppTextKey::PersonalOrdersDetailStatusLabel", "AppTextKey::PersonalOrdersDetailFulfillmentLabel", "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", @@ -403,11 +402,6 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersRepeatDemandNotePartialSingle", "AppTextKey::PersonalOrdersRepeatDemandNotePartialMultiple", "AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable", - "AppTextKey::PersonalOrdersStatusPlaced", - "AppTextKey::PersonalOrdersStatusScheduled", - "AppTextKey::PersonalOrdersStatusReady", - "AppTextKey::PersonalOrdersStatusCompleted", - "AppTextKey::PersonalOrdersStatusRefunded", "AppTextKey::PersonalCartSurfaceBody", "AppTextKey::PersonalOrderSummaryTitle", "AppTextKey::PersonalFulfillmentTitle", @@ -463,9 +457,14 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailEmptyBody", "AppTextKey::OrdersDetailItemsTitle", "AppTextKey::OrdersDetailCustomerLabel", - "AppTextKey::OrdersDetailStatusLabel", "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", + "AppTextKey::TradeWorkflowAxisAgreement", + "AppTextKey::TradeWorkflowAxisRevision", + "AppTextKey::TradeWorkflowAxisFulfillment", + "AppTextKey::TradeWorkflowAxisInventory", + "AppTextKey::TradeWorkflowAxisPayment", + "AppTextKey::TradeWorkflowAxisSource", "AppTextKey::OrdersRecoverySectionTitle", "AppTextKey::OrdersRecoveryMissedPickupTitle", "AppTextKey::OrdersRecoveryMissedPickupBody", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -65,7 +65,9 @@ use radroots_app_view::{ RecoveryKind, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, - TodaySetupTaskKind, TradeEconomicsProjection, TradePaymentDisplayStatus, + TodaySetupTaskKind, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, + TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowProjection, + TradeWorkflowSource, }; use radroots_nostr::prelude::RadrootsNostrClient; use std::{ @@ -4136,16 +4138,13 @@ impl HomeView { app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) .child(app_heading_section(detail.order_number.clone())) .child(home_body_text(detail.customer_display_name.clone())) + .child(trade_workflow_detail_badge_strip(&detail.workflow)) .child(label_value_list([ LabelValueRow::new( app_shared_text(AppTextKey::OrdersDetailCustomerLabel), detail.customer_display_name.clone(), ), LabelValueRow::new( - app_shared_text(AppTextKey::OrdersDetailStatusLabel), - app_shared_text(orders_status_key(detail.status)), - ), - LabelValueRow::new( app_shared_text(AppTextKey::OrdersDetailWindowLabel), order_optional_text(detail.fulfillment_window_label.as_deref()), ), @@ -4155,11 +4154,7 @@ impl HomeView { ), LabelValueRow::new( app_shared_text(AppTextKey::OrdersDetailTotalLabel), - trade_economics_total_text(&detail.economics), - ), - LabelValueRow::new( - app_shared_text(AppTextKey::OrdersDetailPaymentLabel), - app_shared_text(trade_payment_display_status_key(detail.payment)), + trade_economics_total_text(&detail.workflow.economics), ), ])) .child(app_form_section( @@ -8597,6 +8592,141 @@ fn trade_economics_total_text(economics: &TradeEconomicsProjection) -> String { .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()) } +fn trade_workflow_detail_badge_strip(workflow: &TradeWorkflowProjection) -> AnyElement { + let mut badges = vec![ + trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisAgreement, + trade_agreement_status_key(workflow.agreement), + ), + trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisRevision, + trade_revision_status_key(workflow.revision), + ), + ]; + + if let Some(fulfillment) = workflow.fulfillment { + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisFulfillment, + trade_fulfillment_status_key(fulfillment), + )); + } + + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisInventory, + trade_inventory_status_key(workflow.inventory), + )); + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisPayment, + trade_payment_display_status_key(workflow.payment), + )); + if workflow.provenance.primary_source != TradeWorkflowSource::Unknown { + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisSource, + trade_workflow_source_key(workflow.provenance.primary_source), + )); + } + + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .children(badges) + .into_any_element() +} + +fn trade_workflow_list_badge_strip(workflow: &TradeWorkflowProjection) -> AnyElement { + let mut badges = vec![trade_workflow_value_badge(trade_agreement_status_key( + workflow.agreement, + ))]; + + if workflow.revision != TradeRevisionStatus::None { + badges.push(trade_workflow_value_badge(trade_revision_status_key( + workflow.revision, + ))); + } + + if let Some(fulfillment) = workflow.fulfillment { + badges.push(trade_workflow_value_badge(trade_fulfillment_status_key( + fulfillment, + ))); + } + + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisPayment, + trade_payment_display_status_key(workflow.payment), + )); + + app_cluster(APP_UI_THEME.foundation.spacing.tight_px) + .w_full() + .children(badges) + .into_any_element() +} + +fn trade_workflow_status_stack(workflow: &TradeWorkflowProjection) -> AnyElement { + app_stack_v(2.0) + .min_w_0() + .child(trade_workflow_value_badge(trade_agreement_status_key( + workflow.agreement, + ))) + .when_some(workflow.fulfillment, |this, fulfillment| { + this.child(trade_workflow_value_badge(trade_fulfillment_status_key( + fulfillment, + ))) + }) + .into_any_element() +} + +fn trade_workflow_labeled_key_badge(label_key: AppTextKey, value_key: AppTextKey) -> AnyElement { + settings_badge_text(format!("{}: {}", app_text(label_key), app_text(value_key))) + .into_any_element() +} + +fn trade_workflow_value_badge(value_key: AppTextKey) -> AnyElement { + settings_badge_text(app_shared_text(value_key)).into_any_element() +} + +fn trade_agreement_status_key(status: TradeAgreementStatus) -> AppTextKey { + match status { + TradeAgreementStatus::Ordered => AppTextKey::TradeWorkflowAgreementOrdered, + TradeAgreementStatus::Confirmed => AppTextKey::TradeWorkflowAgreementConfirmed, + TradeAgreementStatus::Declined => AppTextKey::TradeWorkflowAgreementDeclined, + TradeAgreementStatus::Cancelled => AppTextKey::TradeWorkflowAgreementCancelled, + TradeAgreementStatus::Completed => AppTextKey::TradeWorkflowAgreementCompleted, + TradeAgreementStatus::NeedsReview => AppTextKey::TradeWorkflowAgreementNeedsReview, + } +} + +fn trade_revision_status_key(status: TradeRevisionStatus) -> AppTextKey { + match status { + TradeRevisionStatus::None => AppTextKey::TradeWorkflowRevisionNone, + TradeRevisionStatus::ChangeProposed => AppTextKey::TradeWorkflowRevisionChangeProposed, + TradeRevisionStatus::Updated => AppTextKey::TradeWorkflowRevisionUpdated, + TradeRevisionStatus::KeptAsPlaced => AppTextKey::TradeWorkflowRevisionKeptAsPlaced, + } +} + +fn trade_fulfillment_status_key(status: TradeFulfillmentStatus) -> AppTextKey { + match status { + TradeFulfillmentStatus::Confirmed => AppTextKey::TradeWorkflowFulfillmentConfirmed, + TradeFulfillmentStatus::Preparing => AppTextKey::TradeWorkflowFulfillmentPreparing, + TradeFulfillmentStatus::ReadyForPickup => { + AppTextKey::TradeWorkflowFulfillmentReadyForPickup + } + TradeFulfillmentStatus::OutForDelivery => { + AppTextKey::TradeWorkflowFulfillmentOutForDelivery + } + TradeFulfillmentStatus::Delivered => AppTextKey::TradeWorkflowFulfillmentDelivered, + TradeFulfillmentStatus::Cancelled => AppTextKey::TradeWorkflowFulfillmentCancelled, + } +} + +fn trade_inventory_status_key(status: TradeInventoryStatus) -> AppTextKey { + match status { + TradeInventoryStatus::Available => AppTextKey::TradeWorkflowInventoryAvailable, + TradeInventoryStatus::Reserved => AppTextKey::TradeWorkflowInventoryReserved, + TradeInventoryStatus::SoldOut => AppTextKey::TradeWorkflowInventorySoldOut, + TradeInventoryStatus::NeedsReview => AppTextKey::TradeWorkflowInventoryNeedsReview, + } +} + fn trade_payment_display_status_key(status: TradePaymentDisplayStatus) -> AppTextKey { match status { TradePaymentDisplayStatus::NotRecorded => AppTextKey::TradeWorkflowPaymentNotRecorded, @@ -8605,6 +8735,16 @@ fn trade_payment_display_status_key(status: TradePaymentDisplayStatus) -> AppTex } } +fn trade_workflow_source_key(source: TradeWorkflowSource) -> AppTextKey { + match source { + TradeWorkflowSource::App => AppTextKey::TradeWorkflowProvenanceApp, + TradeWorkflowSource::Cli => AppTextKey::TradeWorkflowProvenanceCli, + TradeWorkflowSource::Relay => AppTextKey::TradeWorkflowProvenanceRelay, + TradeWorkflowSource::LocalEvents => AppTextKey::TradeWorkflowProvenanceLocalEvents, + TradeWorkflowSource::Unknown => AppTextKey::TradeWorkflowProvenanceUnknown, + } +} + fn buyer_orders_list_card( rows: &[BuyerOrdersListRow], selected_order_id: Option<OrderId>, @@ -8703,10 +8843,13 @@ fn buyer_orders_list_entry( .utility_title_text_px)) .font_weight(gpui::FontWeight::MEDIUM) .text_color(rgb(APP_UI_THEME.foundation.text.primary)) - .child(app_shared_text(buyer_orders_status_key(row.status))), + .child(app_shared_text(trade_agreement_status_key( + row.workflow.agreement, + ))), ), ), ) + .child(trade_workflow_list_badge_strip(&row.workflow)) .child(buyer_listing_chip(row.fulfillment_summary.clone())), ) .into_any_element() @@ -8726,26 +8869,19 @@ fn buyer_order_detail_card( .w_full() .child(app_heading_section(detail.order_number.clone())) .child(settings_badge_text(detail.farm_display_name.clone())) + .child(trade_workflow_detail_badge_strip(&detail.workflow)) .child(label_value_list([ LabelValueRow::new( app_shared_text(AppTextKey::PersonalOrdersDetailFarmLabel), detail.farm_display_name.clone(), ), LabelValueRow::new( - app_shared_text(AppTextKey::PersonalOrdersDetailStatusLabel), - app_shared_text(buyer_orders_status_key(detail.status)), - ), - LabelValueRow::new( app_shared_text(AppTextKey::PersonalOrdersDetailFulfillmentLabel), detail.fulfillment_summary.clone(), ), LabelValueRow::new( app_shared_text(AppTextKey::PersonalOrdersDetailTotalLabel), - trade_economics_total_text(&detail.economics), - ), - LabelValueRow::new( - app_shared_text(AppTextKey::PersonalOrdersDetailPaymentLabel), - app_shared_text(trade_payment_display_status_key(detail.payment)), + trade_economics_total_text(&detail.workflow.economics), ), LabelValueRow::new( app_shared_text(AppTextKey::PersonalOrdersDetailNoteLabel), @@ -8885,17 +9021,6 @@ fn buyer_order_detail_empty_card() -> impl IntoElement { ) } -fn buyer_orders_status_key(status: BuyerOrderStatus) -> AppTextKey { - match status { - BuyerOrderStatus::Placed => AppTextKey::PersonalOrdersStatusPlaced, - BuyerOrderStatus::Scheduled => AppTextKey::PersonalOrdersStatusScheduled, - BuyerOrderStatus::Ready => AppTextKey::PersonalOrdersStatusReady, - BuyerOrderStatus::Completed => AppTextKey::PersonalOrdersStatusCompleted, - BuyerOrderStatus::Declined => AppTextKey::PersonalOrdersStatusDeclined, - BuyerOrderStatus::Refunded => AppTextKey::PersonalOrdersStatusRefunded, - } -} - fn buyer_orders_status_color(status: BuyerOrderStatus) -> u32 { match status { BuyerOrderStatus::Placed => APP_UI_THEME.components.app_status_indicator.attention, @@ -10055,7 +10180,7 @@ fn orders_table_header() -> impl IntoElement { )) .child(products_table_header_column( AppTextKey::OrdersColumnStatus, - Some(128.0), + Some(160.0), false, )) .child(products_table_header_column( @@ -10088,17 +10213,12 @@ fn orders_table_row( .child(order) .child( div() - .w(px(128.0)) + .w(px(160.0)) .flex() - .items_center() + .items_start() .gap(px(6.0)) .child(status_indicator(orders_status_color(row.status))) - .child( - div() - .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) - .text_color(rgb(APP_UI_THEME.foundation.text.primary)) - .child(app_shared_text(orders_status_key(row.status))), - ), + .child(trade_workflow_status_stack(&row.workflow)), ) .child( div() @@ -10170,17 +10290,6 @@ fn orders_empty_state_card(filter: OrdersFilter) -> impl IntoElement { home_empty_state_card(title_key, body_key) } -fn orders_status_key(status: OrderStatus) -> AppTextKey { - match status { - OrderStatus::NeedsAction => AppTextKey::OrdersStatusNeedsAction, - OrderStatus::Scheduled => AppTextKey::OrdersStatusScheduled, - OrderStatus::Packed => AppTextKey::OrdersStatusPacked, - OrderStatus::Completed => AppTextKey::OrdersStatusCompleted, - OrderStatus::Declined => AppTextKey::OrdersStatusDeclined, - OrderStatus::Refunded => AppTextKey::OrdersStatusRefunded, - } -} - fn orders_status_color(status: OrderStatus) -> u32 { match status { OrderStatus::NeedsAction => APP_UI_THEME.components.app_status_indicator.attention, @@ -13091,8 +13200,8 @@ mod tests { about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, about_runtime_rows, about_status_rows, app_text, buyer_order_coordination_notice_forces_redraw, buyer_orders_retry_action_visible, - buyer_orders_status_key, farm_setup_onboarding_card_spec, farmer_home_farm_state, - farmer_pack_day_available, home_auto_focus_target, home_content_scroll_id, home_saved_farm, + farm_setup_onboarding_card_spec, farmer_home_farm_state, farmer_pack_day_available, + home_auto_focus_target, home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, home_window_launch_size_px, home_window_minimum_size_px, pack_day_batch_print_action_presentation, pack_day_batch_print_status_presentation, pack_day_export_action_enabled, @@ -13143,7 +13252,7 @@ mod tests { ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, - TradeEconomicsProjection, TradePaymentDisplayStatus, + TradeEconomicsProjection, TradePaymentDisplayStatus, TradeWorkflowProjection, }; use radroots_identity::RadrootsIdentity; use std::{ @@ -13609,6 +13718,10 @@ mod tests { farm_display_name: String::new(), fulfillment_summary: String::new(), status: BuyerOrderStatus::Placed, + workflow: TradeWorkflowProjection::from_buyer_order_status( + order_id, + BuyerOrderStatus::Placed, + ), repeat_demand: None, }]; buyer_orders.personal_projection.orders.detail = Some(BuyerOrderDetailProjection { @@ -13621,6 +13734,10 @@ mod tests { items: Vec::new(), economics: TradeEconomicsProjection::default(), payment: TradePaymentDisplayStatus::NotRecorded, + workflow: TradeWorkflowProjection::from_buyer_order_status( + order_id, + BuyerOrderStatus::Placed, + ), order_note: None, repeat_demand: Some(RepeatDemandHandoffProjection { order_id, @@ -13725,20 +13842,26 @@ mod tests { ActiveSurface::Farmer, ShellSection::Farmer(FarmerSection::Orders), ); + let farmer_order_id = OrderId::new(); + let farmer_order_farm_id = FarmId::new(); orders.orders_projection.list.rows = vec![OrdersListRow { - order_id: OrderId::new(), - farm_id: FarmId::new(), + order_id: farmer_order_id, + farm_id: farmer_order_farm_id, fulfillment_window_id: None, order_number: String::new(), customer_display_name: String::new(), fulfillment_window_label: None, pickup_location_label: None, status: OrderStatus::Scheduled, + workflow: TradeWorkflowProjection::from_order_status( + farmer_order_id, + OrderStatus::Scheduled, + ), primary_action: Some(OrderPrimaryAction::MarkPacked), }]; orders.orders_projection.detail = Some(OrderDetailProjection { - order_id: OrderId::new(), - farm_id: FarmId::new(), + order_id: farmer_order_id, + farm_id: farmer_order_farm_id, order_number: String::new(), customer_display_name: String::new(), status: OrderStatus::Scheduled, @@ -13748,6 +13871,10 @@ mod tests { items: Vec::new(), economics: TradeEconomicsProjection::default(), payment: TradePaymentDisplayStatus::NotRecorded, + workflow: TradeWorkflowProjection::from_order_status( + farmer_order_id, + OrderStatus::Scheduled, + ), primary_action: Some(OrderPrimaryAction::MarkPacked), recoveries: Vec::new(), }); @@ -13794,18 +13921,6 @@ mod tests { } #[test] - fn buyer_orders_status_keys_use_buyer_facing_copy() { - assert_eq!( - buyer_orders_status_key(BuyerOrderStatus::Placed), - AppTextKey::PersonalOrdersStatusPlaced - ); - assert_eq!( - buyer_orders_status_key(BuyerOrderStatus::Ready), - AppTextKey::PersonalOrdersStatusReady - ); - } - - #[test] fn farmer_home_farm_state_distinguishes_no_farm_incomplete_and_configured() { let farm_id = FarmId::new(); let incomplete_farm = FarmSummary { diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -227,6 +227,12 @@ define_app_text_keys! { OrdersRecoveryStateOpen => "orders.recovery.state.open", OrdersRecoveryStateInReview => "orders.recovery.state.in_review", OrdersRecoveryStateResolved => "orders.recovery.state.resolved", + TradeWorkflowAxisAgreement => "trade.workflow.axis.agreement", + TradeWorkflowAxisRevision => "trade.workflow.axis.revision", + TradeWorkflowAxisFulfillment => "trade.workflow.axis.fulfillment", + TradeWorkflowAxisInventory => "trade.workflow.axis.inventory", + TradeWorkflowAxisPayment => "trade.workflow.axis.payment", + TradeWorkflowAxisSource => "trade.workflow.axis.source", TradeWorkflowAgreementOrdered => "trade.workflow.agreement.ordered", TradeWorkflowAgreementConfirmed => "trade.workflow.agreement.confirmed", TradeWorkflowAgreementDeclined => "trade.workflow.agreement.declined", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -380,6 +380,16 @@ mod tests { #[test] fn english_trade_workflow_copy_matches_the_projection_contract() { assert_eq!( + app_text(AppTextKey::TradeWorkflowAxisAgreement), + "Agreement" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowAxisFulfillment), + "Fulfillment" + ); + assert_eq!(app_text(AppTextKey::TradeWorkflowAxisPayment), "Payment"); + assert_eq!(app_text(AppTextKey::TradeWorkflowAxisSource), "Source"); + assert_eq!( app_text(AppTextKey::TradeWorkflowAgreementOrdered), "Ordered" ); diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs @@ -2275,6 +2275,7 @@ mod tests { ReminderLogEntryProjection, ReminderLogProjection, SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TradeEconomicsProjection, TradePaymentDisplayStatus, + TradeWorkflowProjection, }; struct FailingRepository; @@ -2500,6 +2501,13 @@ mod tests { let farm_id = FarmId::new(); let fulfillment_window_id = FulfillmentWindowId::new(); let order_id = OrderId::new(); + let order_economics = TradeEconomicsProjection { + subtotal_minor_units: Some(1300), + total_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + ..TradeEconomicsProjection::default() + }; + let order_payment = TradePaymentDisplayStatus::NotRecorded; let orders_list = OrdersListProjection { summary: OrdersListSummary { total_orders: 2, @@ -2516,6 +2524,10 @@ mod tests { fulfillment_window_label: Some("Friday pickup".to_owned()), pickup_location_label: Some("North barn".to_owned()), status: OrderStatus::NeedsAction, + workflow: TradeWorkflowProjection::from_order_status( + order_id, + OrderStatus::NeedsAction, + ), primary_action: Some(OrderPrimaryAction::Review), }], }; @@ -2538,13 +2550,13 @@ mod tests { }), line_total_minor_units: Some(1300), }], - economics: TradeEconomicsProjection { - subtotal_minor_units: Some(1300), - total_minor_units: Some(1300), - currency_code: Some("USD".to_owned()), - ..TradeEconomicsProjection::default() - }, - payment: TradePaymentDisplayStatus::NotRecorded, + economics: order_economics.clone(), + payment: order_payment, + workflow: TradeWorkflowProjection::from_order_status( + order_id, + OrderStatus::NeedsAction, + ) + .with_economics_and_payment(order_economics, order_payment), primary_action: Some(OrderPrimaryAction::Review), recoveries: Vec::new(), }; diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -9,6 +9,7 @@ use radroots_app_view::{ OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, RepeatDemandEligibility, RepeatDemandHandoffProjection, TradePaymentDisplayStatus, + TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; @@ -786,14 +787,17 @@ impl<'a> AppBuyerRepository<'a> { operation: "read buyer orders list", source, })?; + let order_id = parse_typed_id("orders.id", order_id)?; + let farm_id = parse_typed_id("orders.farm_id", farm_id)?; + let buyer_status = BuyerOrderStatus::from(parse_order_status("orders.status", status)?); orders.push(BuyerOrdersListRow { - order_id: parse_typed_id("orders.id", order_id.clone())?, - farm_id: parse_typed_id("orders.farm_id", farm_id.clone())?, + order_id, + farm_id, order_number, repeat_demand: self.build_repeat_demand_handoff( - parse_typed_id("orders.id", order_id)?, - parse_typed_id("orders.farm_id", farm_id)?, + order_id, + farm_id, farm_display_name.as_str(), &visible_listings, )?, @@ -803,7 +807,8 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_starts_at, fulfillment_ends_at, ), - status: BuyerOrderStatus::from(parse_order_status("orders.status", status)?), + status: buyer_status, + workflow: TradeWorkflowProjection::from_buyer_order_status(order_id, buyer_status), }); } @@ -872,8 +877,14 @@ impl<'a> AppBuyerRepository<'a> { )| { let order_id: OrderId = parse_typed_id("orders.id", order_id)?; let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; + let status = + BuyerOrderStatus::from(parse_order_status("orders.status", status)?); let items = self.load_order_detail_items(order_id.to_string())?; let economics = order_detail_economics(&items)?; + let payment = TradePaymentDisplayStatus::NotRecorded; + let workflow = + TradeWorkflowProjection::from_buyer_order_status(order_id, status) + .with_economics_and_payment(economics.clone(), payment); Ok(BuyerOrderDetailProjection { order_id, farm_id, @@ -884,13 +895,11 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_starts_at, fulfillment_ends_at, ), - status: BuyerOrderStatus::from(parse_order_status( - "orders.status", - status, - )?), + status, items, economics, - payment: TradePaymentDisplayStatus::NotRecorded, + payment, + workflow, order_note: empty_string_to_none(order_note), repeat_demand: self.build_repeat_demand_handoff( order_id, diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -7,7 +7,7 @@ use radroots_app_view::{ PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PackDayScreenQueryState, ProductId, TradePaymentDisplayStatus, + PackDayScreenQueryState, ProductId, TradePaymentDisplayStatus, TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -113,12 +113,17 @@ impl<'a> AppOrdersRepository<'a> { fulfillment_window_label, pickup_location_label, )| { + let order_id: OrderId = parse_typed_id("orders.id", order_id)?; + let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; let status = parse_order_status("orders.status", status)?; - let items = self.load_order_detail_items(order_id.clone())?; + let items = self.load_order_detail_items(order_id.to_string())?; let economics = order_detail_economics(&items)?; + let payment = TradePaymentDisplayStatus::NotRecorded; + let workflow = TradeWorkflowProjection::from_order_status(order_id, status) + .with_economics_and_payment(economics.clone(), payment); Ok(OrderDetailProjection { - order_id: parse_typed_id("orders.id", order_id)?, - farm_id: parse_typed_id("orders.farm_id", farm_id)?, + order_id, + farm_id, order_number, customer_display_name, status, @@ -130,7 +135,8 @@ impl<'a> AppOrdersRepository<'a> { pickup_location_label: empty_string_to_none(pickup_location_label), items, economics, - payment: TradePaymentDisplayStatus::NotRecorded, + payment, + workflow, primary_action: primary_action_for_status(status), recoveries: Vec::new(), }) @@ -1126,6 +1132,8 @@ impl OrderRecord { } fn into_list_row(self) -> OrdersListRow { + let workflow = TradeWorkflowProjection::from_order_status(self.order_id, self.status); + OrdersListRow { order_id: self.order_id, farm_id: self.farm_id, @@ -1135,6 +1143,7 @@ impl OrderRecord { fulfillment_window_label: self.fulfillment_window_label, pickup_location_label: self.pickup_location_label, status: self.status, + workflow, primary_action: primary_action_for_status(self.status), } } diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1448,6 +1448,96 @@ impl TradeWorkflowProjection { TradeAgreementStatus::from_reducer_status(agreement), ) } + + pub fn from_order_status(order_id: OrderId, status: OrderStatus) -> Self { + let mut projection = match status { + OrderStatus::NeedsAction => Self::new(order_id, TradeAgreementStatus::Ordered), + OrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed), + OrderStatus::Packed => Self::new(order_id, TradeAgreementStatus::Confirmed), + OrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Completed), + OrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), + OrderStatus::Refunded => Self::new(order_id, TradeAgreementStatus::NeedsReview), + }; + + match status { + OrderStatus::NeedsAction => {} + OrderStatus::Scheduled => { + projection.fulfillment = Some(TradeFulfillmentStatus::Confirmed); + projection.inventory = TradeInventoryStatus::Reserved; + } + OrderStatus::Packed => { + projection.fulfillment = Some(TradeFulfillmentStatus::ReadyForPickup); + projection.inventory = TradeInventoryStatus::Reserved; + } + OrderStatus::Completed => { + projection.fulfillment = Some(TradeFulfillmentStatus::Delivered); + projection.inventory = TradeInventoryStatus::Reserved; + } + OrderStatus::Declined => { + projection.fulfillment = Some(TradeFulfillmentStatus::Cancelled); + projection.inventory = TradeInventoryStatus::Available; + } + OrderStatus::Refunded => { + projection.payment = TradePaymentDisplayStatus::NeedsReview; + } + } + + projection + } + + pub fn from_buyer_order_status(order_id: OrderId, status: BuyerOrderStatus) -> Self { + let mut projection = match status { + BuyerOrderStatus::Placed => Self::new(order_id, TradeAgreementStatus::Ordered), + BuyerOrderStatus::Scheduled => Self::new(order_id, TradeAgreementStatus::Confirmed), + BuyerOrderStatus::Ready => Self::new(order_id, TradeAgreementStatus::Confirmed), + BuyerOrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Completed), + BuyerOrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), + BuyerOrderStatus::Refunded => Self::new(order_id, TradeAgreementStatus::NeedsReview), + }; + + match status { + BuyerOrderStatus::Placed => {} + BuyerOrderStatus::Scheduled => { + projection.fulfillment = Some(TradeFulfillmentStatus::Confirmed); + projection.inventory = TradeInventoryStatus::Reserved; + } + BuyerOrderStatus::Ready => { + projection.fulfillment = Some(TradeFulfillmentStatus::ReadyForPickup); + projection.inventory = TradeInventoryStatus::Reserved; + } + BuyerOrderStatus::Completed => { + projection.fulfillment = Some(TradeFulfillmentStatus::Delivered); + projection.inventory = TradeInventoryStatus::Reserved; + } + BuyerOrderStatus::Declined => { + projection.fulfillment = Some(TradeFulfillmentStatus::Cancelled); + projection.inventory = TradeInventoryStatus::Available; + } + BuyerOrderStatus::Refunded => { + projection.payment = TradePaymentDisplayStatus::NeedsReview; + } + } + + projection + } + + pub fn with_economics(mut self, economics: TradeEconomicsProjection) -> Self { + self.economics = economics; + self + } + + pub fn with_payment(mut self, payment: TradePaymentDisplayStatus) -> Self { + self.payment = payment; + self + } + + pub fn with_economics_and_payment( + self, + economics: TradeEconomicsProjection, + payment: TradePaymentDisplayStatus, + ) -> Self { + self.with_economics(economics).with_payment(payment) + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -1523,6 +1613,7 @@ pub struct OrdersListRow { pub fulfillment_window_label: Option<String>, pub pickup_location_label: Option<String>, pub status: OrderStatus, + pub workflow: TradeWorkflowProjection, pub primary_action: Option<OrderPrimaryAction>, } @@ -1559,6 +1650,7 @@ pub struct OrderDetailProjection { pub items: Vec<OrderDetailItemRow>, pub economics: TradeEconomicsProjection, pub payment: TradePaymentDisplayStatus, + pub workflow: TradeWorkflowProjection, pub primary_action: Option<OrderPrimaryAction>, pub recoveries: Vec<OrderRecoveryProjection>, } @@ -1571,6 +1663,7 @@ pub struct BuyerOrdersListRow { pub farm_display_name: String, pub fulfillment_summary: String, pub status: BuyerOrderStatus, + pub workflow: TradeWorkflowProjection, pub repeat_demand: Option<RepeatDemandHandoffProjection>, } @@ -1596,6 +1689,7 @@ pub struct BuyerOrderDetailProjection { pub items: Vec<OrderDetailItemRow>, pub economics: TradeEconomicsProjection, pub payment: TradePaymentDisplayStatus, + pub workflow: TradeWorkflowProjection, pub order_note: Option<String>, pub repeat_demand: Option<RepeatDemandHandoffProjection>, } @@ -3101,6 +3195,13 @@ mod tests { let fulfillment_window_id = super::FulfillmentWindowId::new(); let farm_id = FarmId::new(); let order_id = super::OrderId::new(); + let order_economics = TradeEconomicsProjection { + subtotal_minor_units: Some(1300), + total_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + ..TradeEconomicsProjection::default() + }; + let order_payment = TradePaymentDisplayStatus::NotRecorded; let orders_list = OrdersListProjection { summary: OrdersListSummary { total_orders: 3, @@ -3117,6 +3218,10 @@ mod tests { fulfillment_window_label: Some("Wednesday pickup".to_owned()), pickup_location_label: Some("North barn".to_owned()), status: OrderStatus::Scheduled, + workflow: TradeWorkflowProjection::from_order_status( + order_id, + OrderStatus::Scheduled, + ), primary_action: Some(OrderPrimaryAction::MarkPacked), }], }; @@ -3139,13 +3244,10 @@ mod tests { }), line_total_minor_units: Some(1300), }], - economics: TradeEconomicsProjection { - subtotal_minor_units: Some(1300), - total_minor_units: Some(1300), - currency_code: Some("USD".to_owned()), - ..TradeEconomicsProjection::default() - }, - payment: TradePaymentDisplayStatus::NotRecorded, + economics: order_economics.clone(), + payment: order_payment, + workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled) + .with_economics_and_payment(order_economics, order_payment), primary_action: Some(OrderPrimaryAction::MarkPacked), recoveries: Vec::new(), }; @@ -3178,7 +3280,15 @@ mod tests { orders_list.rows[0].primary_action, Some(OrderPrimaryAction::MarkPacked) ); + assert_eq!( + orders_list.rows[0].workflow.agreement, + TradeAgreementStatus::Confirmed + ); assert_eq!(order_detail.items[0].quantity_display, "2 bags"); + assert_eq!( + order_detail.workflow.fulfillment, + Some(TradeFulfillmentStatus::Confirmed) + ); assert!(!pack_day.is_empty()); assert_eq!(pack_day.pickup_roster[0].order_number, "R-1001"); } @@ -3188,6 +3298,13 @@ mod tests { let farm_id = FarmId::new(); let product_id = super::ProductId::new(); let order_id = super::OrderId::new(); + let buyer_order_economics = TradeEconomicsProjection { + subtotal_minor_units: Some(1300), + total_minor_units: Some(1300), + currency_code: Some("USD".to_owned()), + ..TradeEconomicsProjection::default() + }; + let buyer_order_payment = TradePaymentDisplayStatus::NotRecorded; let listing = BuyerListingRow { product_id, farm_id, @@ -3261,6 +3378,10 @@ mod tests { farm_display_name: "Cedar Grove Farm".to_owned(), fulfillment_summary: "Thursday pickup".to_owned(), status: BuyerOrderStatus::Scheduled, + workflow: TradeWorkflowProjection::from_buyer_order_status( + order_id, + BuyerOrderStatus::Scheduled, + ), repeat_demand: None, }], }; @@ -3281,13 +3402,13 @@ mod tests { }), line_total_minor_units: Some(1300), }], - economics: TradeEconomicsProjection { - subtotal_minor_units: Some(1300), - total_minor_units: Some(1300), - currency_code: Some("USD".to_owned()), - ..TradeEconomicsProjection::default() - }, - payment: TradePaymentDisplayStatus::NotRecorded, + economics: buyer_order_economics.clone(), + payment: buyer_order_payment, + workflow: TradeWorkflowProjection::from_buyer_order_status( + order_id, + BuyerOrderStatus::Scheduled, + ) + .with_economics_and_payment(buyer_order_economics, buyer_order_payment), order_note: Some("Leave by the cooler".to_owned()), repeat_demand: None, }; @@ -3298,6 +3419,10 @@ mod tests { assert!(!orders.is_empty()); assert_eq!(listing.fulfillment_methods.len(), 1); assert_eq!(order_detail.status, BuyerOrderStatus::Scheduled); + assert_eq!( + order_detail.workflow.agreement, + TradeAgreementStatus::Confirmed + ); } #[test] @@ -3313,8 +3438,9 @@ mod tests { }], ..TodayAgendaProjection::default() }; + let orders_row_id = super::OrderId::new(); let orders_row = OrdersListRow { - order_id: super::OrderId::new(), + order_id: orders_row_id, farm_id: FarmId::new(), fulfillment_window_id: None, order_number: "R-2002".to_owned(), @@ -3322,6 +3448,10 @@ mod tests { fulfillment_window_label: None, pickup_location_label: None, status: OrderStatus::Completed, + workflow: TradeWorkflowProjection::from_order_status( + orders_row_id, + OrderStatus::Completed, + ), primary_action: None, }; diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -207,6 +207,12 @@ "orders.recovery.state.open": "Open", "orders.recovery.state.in_review": "In review", "orders.recovery.state.resolved": "Resolved", + "trade.workflow.axis.agreement": "Agreement", + "trade.workflow.axis.revision": "Change", + "trade.workflow.axis.fulfillment": "Fulfillment", + "trade.workflow.axis.inventory": "Stock", + "trade.workflow.axis.payment": "Payment", + "trade.workflow.axis.source": "Source", "trade.workflow.agreement.ordered": "Ordered", "trade.workflow.agreement.confirmed": "Confirmed", "trade.workflow.agreement.declined": "Declined",