app

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

commit fccfdb952a2f2f7afdcba47484271ea7e8dc971f
parent 2ae5a22861f924a3d114cbe762f449ce05baa244
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Apr 2026 23:46:48 +0000

orders: land orders screen and detail entry

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 657+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/launchers/desktop/src/source_guards.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 587+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/i18n/src/keys.rs | 31+++++++++++++++++++++++++++++++
Mcrates/shared/i18n/src/lib.rs | 20++++++++++++++++++++
Mi18n/locales/en/messages.json | 31+++++++++++++++++++++++++++++++
6 files changed, 1204 insertions(+), 179 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -6,7 +6,7 @@ use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, FarmId, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, - LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersListProjection, + LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, @@ -16,8 +16,8 @@ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, }; use radroots_app_sqlite::{ - derive_farm_rules_readiness, AppSqliteError, AppSqliteStore, DatabaseTarget, - APP_ACTIVITY_CONTEXT_LIMIT, + APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, + derive_farm_rules_readiness, }; use radroots_app_state::{ AppShellProjection, AppStateCommand, AppStateStore, AppStateStoreError, FarmSetupFlowStage, @@ -30,15 +30,15 @@ use thiserror::Error; use tracing::error; use crate::accounts::{ - bootstrap_desktop_accounts, generate_local_account, identity_projection_from_manager, - import_local_account, remove_selected_local_key, reset_local_device_state, - select_active_surface, select_local_account, DesktopAccountsBootstrapError, - DesktopAccountsCommandError, DesktopAccountsProjectionError, DesktopLocalIdentityImportRequest, + DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopAccountsProjectionError, + DesktopLocalIdentityImportRequest, bootstrap_desktop_accounts, generate_local_account, + identity_projection_from_manager, import_local_account, remove_selected_local_key, + reset_local_device_state, select_active_surface, select_local_account, }; use crate::remote_signer::{ - activate_pending_session, apply_remote_signer_custody, clear_pending_session, - load_pending_session, purge_all_state, reconcile_startup, store_pending_session, - DesktopRemoteSignerError, DesktopRemoteSignerPaths, + DesktopRemoteSignerError, DesktopRemoteSignerPaths, activate_pending_session, + apply_remote_signer_custody, clear_pending_session, load_pending_session, purge_all_state, + reconcile_startup, store_pending_session, }; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -198,6 +198,10 @@ impl DesktopAppRuntime { self.lock_state_mut().open_products_filter(filter) } + pub fn select_orders_filter(&self, filter: OrdersFilter) -> Result<bool, AppSqliteError> { + self.lock_state_mut().select_orders_filter(filter) + } + pub fn open_orders(&self) -> Result<bool, AppSqliteError> { self.lock_state_mut().open_orders() } @@ -206,6 +210,14 @@ impl DesktopAppRuntime { self.lock_state_mut().open_order_detail(order_id) } + pub fn mark_order_packed(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut().mark_order_packed(order_id) + } + + pub fn mark_order_completed(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut().mark_order_completed(order_id) + } + pub fn open_pack_day( &self, fulfillment_window_id: Option<FulfillmentWindowId>, @@ -739,13 +751,31 @@ impl DesktopAppRuntimeState { Ok(filter_changed || section_changed) } + fn select_orders_filter(&mut self, filter: OrdersFilter) -> Result<bool, AppSqliteError> { + if !self.has_saved_farm() { + return Ok(false); + } + + let query = self.state_store.orders_projection().query.clone(); + if query.filter == filter { + return Ok(false); + } + + self.replace_orders_query(OrdersScreenQueryState { + filter, + fulfillment_window_id: query.fulfillment_window_id, + }) + } + fn open_orders(&mut self) -> Result<bool, AppSqliteError> { if !self.has_saved_farm() { return Ok(false); } - let query_changed = - self.replace_orders_query(self.state_store.orders_projection().query.clone())?; + let query_changed = self.replace_orders_query(OrdersScreenQueryState::default())?; + let detail_changed = self + .state_store + .apply_in_memory(AppStateCommand::replace_order_detail(None)); let section_changed = self .state_store .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( @@ -753,7 +783,7 @@ impl DesktopAppRuntimeState { ))); let editor_changed = self.close_product_editor(); - Ok(query_changed || section_changed || editor_changed) + Ok(query_changed || detail_changed || section_changed || editor_changed) } fn open_order_detail(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { @@ -780,6 +810,58 @@ impl DesktopAppRuntimeState { Ok(detail_changed || section_changed || editor_changed) } + fn mark_order_packed(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + + let updated = sqlite_store.mark_order_packed(farm_id, order_id)?; + if !updated { + return Ok(false); + } + + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); + + Ok(updated || context_changed) + } + + fn mark_order_completed(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + + let updated = sqlite_store.mark_order_completed(farm_id, order_id)?; + if !updated { + return Ok(false); + } + + let selected_account_context = load_selected_account_context( + sqlite_store, + self.state_store.identity_projection(), + self.state_store.products_projection().query.clone(), + self.state_store.orders_projection().query.clone(), + self.selected_order_detail_id(), + self.state_store.pack_day_projection().query.clone(), + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); + + Ok(updated || context_changed) + } + fn open_pack_day( &mut self, fulfillment_window_id: Option<FulfillmentWindowId>, @@ -1694,8 +1776,8 @@ mod tests { FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, - OrdersFilter, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, - ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, + OrderStatus, OrdersFilter, PickupLocationId, PickupLocationRecord, ProductEditorDraft, + ProductStatus, ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; @@ -1716,8 +1798,8 @@ mod tests { use crate::accounts::DesktopLocalIdentityImportRequest; use super::{ - DesktopAppRuntime, DesktopAppRuntimeActivityContextError, DesktopAppRuntimeCommandError, - DesktopAppRuntimeState, DesktopRemoteSignerPaths, APP_DATABASE_FILE_NAME, + APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, + DesktopAppRuntimeCommandError, DesktopAppRuntimeState, DesktopRemoteSignerPaths, }; #[test] @@ -1801,10 +1883,12 @@ mod tests { assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); assert_eq!(summary.home_route, HomeRoute::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); - assert!(summary - .settings_account_projection - .selected_account - .is_none()); + assert!( + summary + .settings_account_projection + .selected_account + .is_none() + ); assert_eq!( summary.logged_out_startup, LoggedOutStartupProjection::default() @@ -1891,9 +1975,11 @@ mod tests { startup_issue: None, }); - assert!(runtime - .clear_startup_pending_remote_signer_session() - .expect("clear pending should succeed")); + assert!( + runtime + .clear_startup_pending_remote_signer_session() + .expect("clear pending should succeed") + ); assert!(runtime.begin_generate_key_startup()); assert_eq!( runtime.summary().logged_out_startup.phase, @@ -1908,9 +1994,11 @@ mod tests { let (runtime, paths) = bootstrapped_runtime("restart_pending_recovery"); let pending_session = fixture_pending_session(); - assert!(runtime - .store_startup_pending_remote_signer_session(&pending_session) - .expect("store pending should succeed")); + assert!( + runtime + .store_startup_pending_remote_signer_session(&pending_session) + .expect("store pending should succeed") + ); let restarted = restart_runtime(paths.clone()); let restored = restarted @@ -1939,12 +2027,16 @@ mod tests { let (runtime, paths) = bootstrapped_runtime("restart_after_explicit_cancel"); let pending_session = fixture_pending_session(); - assert!(runtime - .store_startup_pending_remote_signer_session(&pending_session) - .expect("store pending should succeed")); - assert!(runtime - .clear_startup_pending_remote_signer_session() - .expect("clear pending should succeed")); + assert!( + runtime + .store_startup_pending_remote_signer_session(&pending_session) + .expect("store pending should succeed") + ); + assert!( + runtime + .clear_startup_pending_remote_signer_session() + .expect("clear pending should succeed") + ); let restarted = restart_runtime(paths.clone()); @@ -2201,9 +2293,11 @@ mod tests { fn runtime_routes_between_farmer_home_and_products_through_explicit_methods() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2239,9 +2333,11 @@ mod tests { .expect("sqlite store") .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); assert!(runtime.select_farmer_section(FarmerSection::Products)); assert_eq!( @@ -2260,9 +2356,11 @@ mod tests { fn runtime_products_queries_refresh_the_repository_backed_projection() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2317,9 +2415,11 @@ mod tests { "2026-04-18T09:00:00Z", ); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); let summary = runtime.summary(); assert_eq!(summary.products_projection.list.summary.total_products, 2); @@ -2333,14 +2433,18 @@ mod tests { ProductsSort::default() ); - assert!(runtime - .select_products_filter(ProductsFilter::NeedAttention) - .expect("filter should apply")); + assert!( + runtime + .select_products_filter(ProductsFilter::NeedAttention) + .expect("filter should apply") + ); assert_eq!(runtime.summary().products_projection.list.rows.len(), 2); - assert!(runtime - .set_products_search_query("pea") - .expect("search should apply")); + assert!( + runtime + .set_products_search_query("pea") + .expect("search should apply") + ); let searched = runtime.summary(); assert_eq!(searched.products_projection.list.rows.len(), 1); assert_eq!( @@ -2348,9 +2452,11 @@ mod tests { "Pea shoots" ); - assert!(runtime - .select_products_sort(ProductsSort::Name) - .expect("sort should apply")); + assert!( + runtime + .select_products_sort(ProductsSort::Name) + .expect("sort should apply") + ); assert_eq!( runtime.summary().products_projection.query.sort, ProductsSort::Name @@ -2361,9 +2467,11 @@ mod tests { fn runtime_open_products_filter_routes_today_follow_ons_into_products() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2400,17 +2508,21 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); assert_eq!( runtime.summary().shell_projection.selected_section, ShellSection::Farmer(FarmerSection::Today) ); - assert!(runtime - .open_products_filter(ProductsFilter::Drafts) - .expect("products follow-on should route")); + assert!( + runtime + .open_products_filter(ProductsFilter::Drafts) + .expect("products follow-on should route") + ); let summary = runtime.summary(); assert_eq!( @@ -2447,9 +2559,11 @@ mod tests { ); assert!(orders_summary.orders_projection.detail.is_none()); - assert!(runtime - .open_order_detail(order_id) - .expect("order detail should open")); + assert!( + runtime + .open_order_detail(order_id) + .expect("order detail should open") + ); let detail_summary = runtime.summary(); assert_eq!( detail_summary.shell_projection.selected_section, @@ -2491,12 +2605,155 @@ mod tests { } #[test] + fn runtime_open_orders_resets_to_default_queue_and_clears_detail() { + let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, order_id) = seed_order_workspace(&runtime, farm_id); + + assert!( + runtime + .select_orders_filter(OrdersFilter::Packed) + .expect("orders filter should update") + ); + assert!( + runtime + .open_order_detail(order_id) + .expect("order detail should open") + ); + + assert!(runtime.open_orders().expect("orders should reopen")); + let summary = runtime.summary(); + + assert_eq!( + summary.shell_projection.selected_section, + ShellSection::Farmer(FarmerSection::Orders) + ); + assert_eq!( + summary.orders_projection.query.filter, + OrdersFilter::NeedsAction + ); + assert_eq!(summary.orders_projection.list.rows.len(), 1); + assert!(summary.orders_projection.detail.is_none()); + } + + #[test] + fn runtime_order_actions_refresh_repository_backed_orders_projection() { + let runtime = memory_runtime(); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (_, order_id) = seed_order_workspace(&runtime, farm_id); + + let sql = format!( + "update orders + set status = 'scheduled', updated_at = '2026-04-17T12:00:00Z' + where id = '{order_id}' and farm_id = '{farm_id}'" + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch(&sql) + .expect("order should update to scheduled"); + + assert!( + runtime + .select_orders_filter(OrdersFilter::Scheduled) + .expect("scheduled filter should apply") + ); + assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); + assert_eq!( + runtime.summary().orders_projection.list.rows[0].status, + OrderStatus::Scheduled + ); + + assert!( + runtime + .open_order_detail(order_id) + .expect("order detail should open") + ); + assert!( + runtime + .mark_order_packed(order_id) + .expect("order should mark packed") + ); + let packed_summary = runtime.summary(); + assert_eq!( + packed_summary + .orders_projection + .detail + .as_ref() + .expect("packed detail") + .status, + OrderStatus::Packed + ); + assert_eq!(packed_summary.orders_projection.list.rows.len(), 0); + assert_eq!( + packed_summary + .orders_projection + .list + .summary + .scheduled_orders, + 0 + ); + assert_eq!( + packed_summary.orders_projection.list.summary.packed_orders, + 1 + ); + + assert!( + runtime + .select_orders_filter(OrdersFilter::Packed) + .expect("packed filter should apply") + ); + assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); + assert_eq!( + runtime.summary().orders_projection.list.rows[0].status, + OrderStatus::Packed + ); + + assert!( + runtime + .open_order_detail(order_id) + .expect("packed detail should open") + ); + assert!( + runtime + .mark_order_completed(order_id) + .expect("order should mark completed") + ); + let completed_summary = runtime.summary(); + assert_eq!( + completed_summary + .orders_projection + .detail + .as_ref() + .expect("completed detail") + .status, + OrderStatus::Completed + ); + + assert!( + runtime + .select_orders_filter(OrdersFilter::Completed) + .expect("completed filter should apply") + ); + assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); + assert_eq!( + runtime.summary().orders_projection.list.rows[0].status, + OrderStatus::Completed + ); + } + + #[test] fn runtime_stock_updates_refresh_today_and_products_projections() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2542,18 +2799,22 @@ mod tests { "2026-04-18T10:00:00Z", ); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); let product_id = runtime.summary().products_projection.list.rows[0].product_id; assert_eq!( runtime.summary().today_projection.low_stock_products.len(), 1 ); - assert!(runtime - .update_product_stock(product_id, 12) - .expect("stock update should succeed")); + assert!( + runtime + .update_product_stock(product_id, 12) + .expect("stock update should succeed") + ); let summary = runtime.summary(); assert_eq!( @@ -2567,9 +2828,11 @@ mod tests { fn runtime_open_new_product_editor_creates_a_local_draft_and_opens_it() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2606,9 +2869,11 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); assert_eq!( runtime .summary() @@ -2619,9 +2884,11 @@ mod tests { 0 ); - assert!(runtime - .open_new_product_editor() - .expect("new product editor should open")); + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); let summary = runtime.summary(); assert_eq!(summary.products_projection.list.summary.total_products, 1); @@ -2639,9 +2906,11 @@ mod tests { fn runtime_open_existing_and_save_product_editor_refreshes_products_projection() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2687,12 +2956,16 @@ mod tests { "2026-04-18T10:00:00Z", ); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); - assert!(runtime - .open_existing_product_editor(product_id) - .expect("existing product editor should open")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + assert!( + runtime + .open_existing_product_editor(product_id) + .expect("existing product editor should open") + ); let saved_draft = ProductEditorDraft { title: "Salad mix".to_owned(), @@ -2705,9 +2978,11 @@ mod tests { status: radroots_app_models::ProductStatus::Published, }; - assert!(runtime - .save_product_editor_draft(saved_draft.clone()) - .expect("product editor draft should save")); + assert!( + runtime + .save_product_editor_draft(saved_draft.clone()) + .expect("product editor draft should save") + ); let summary = runtime.summary(); assert_eq!( @@ -2744,9 +3019,11 @@ mod tests { fn runtime_account_commands_refresh_identity_projection() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("First".to_owned())) - .expect("first account should generate")); + assert!( + runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate") + ); let first_summary = runtime.summary(); let first_account_id = first_summary .settings_account_projection @@ -2757,9 +3034,11 @@ mod tests { .account_id .clone(); - assert!(runtime - .generate_local_account(Some("Second".to_owned())) - .expect("second account should generate")); + assert!( + runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate") + ); let second_summary = runtime.summary(); let second_account_id = second_summary .settings_account_projection @@ -2785,9 +3064,11 @@ mod tests { ActiveSurface::Farmer, true, ); - assert!(runtime - .select_local_account(second_account_id.as_str()) - .expect("selection should succeed")); + assert!( + runtime + .select_local_account(second_account_id.as_str()) + .expect("selection should succeed") + ); let selected_summary = runtime.summary(); assert_eq!(selected_summary.startup_gate, AppStartupGate::Farmer); assert_eq!(selected_summary.home_route, HomeRoute::FarmSetupOnboarding); @@ -2800,9 +3081,11 @@ mod tests { Some(ActiveSurface::Farmer) ); - assert!(runtime - .remove_selected_local_key() - .expect("selected local key should remove")); + assert!( + runtime + .remove_selected_local_key() + .expect("selected local key should remove") + ); let removed_summary = runtime.summary(); assert_eq!(removed_summary.settings_account_projection.roster.len(), 1); assert_eq!( @@ -2825,11 +3108,13 @@ mod tests { ); let imported_identity = RadrootsIdentity::generate(); - assert!(runtime - .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( - imported_identity.nsec(), - )) - .expect("raw import should succeed")); + assert!( + runtime + .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( + imported_identity.nsec(), + )) + .expect("raw import should succeed") + ); let imported_summary = runtime.summary(); assert_eq!(imported_summary.settings_account_projection.roster.len(), 2); assert_eq!( @@ -2846,9 +3131,11 @@ mod tests { fn runtime_select_active_surface_persists_selected_surface() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2859,14 +3146,18 @@ mod tests { .account_id .clone(); save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); assert_eq!(runtime.summary().startup_gate, AppStartupGate::Farmer); - assert!(runtime - .select_active_surface(ActiveSurface::Personal) - .expect("surface should select")); + assert!( + runtime + .select_active_surface(ActiveSurface::Personal) + .expect("surface should select") + ); let personal_summary = runtime.summary(); assert_eq!(personal_summary.startup_gate, AppStartupGate::Personal); assert_eq!( @@ -2890,9 +3181,11 @@ mod tests { ActiveSurface::Personal ); - assert!(runtime - .select_active_surface(ActiveSurface::Farmer) - .expect("surface should reselect")); + assert!( + runtime + .select_active_surface(ActiveSurface::Farmer) + .expect("surface should reselect") + ); let farmer_summary = runtime.summary(); assert_eq!(farmer_summary.startup_gate, AppStartupGate::Farmer); assert_eq!( @@ -2921,9 +3214,11 @@ mod tests { fn selecting_farmer_account_loads_persisted_farm_setup_draft() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2947,9 +3242,11 @@ mod tests { .expect("farm setup should save"); save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); let summary = runtime.summary(); assert_eq!(summary.startup_gate, AppStartupGate::Farmer); @@ -2961,9 +3258,11 @@ mod tests { fn finishing_farm_setup_persists_saved_farm_and_today_projection() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -2975,9 +3274,11 @@ mod tests { .clone(); let farm_id = save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupOnboarding); let draft = FarmSetupDraft::new( @@ -3040,9 +3341,11 @@ mod tests { fn loading_farm_rules_projection_seeds_profile_from_saved_farm() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -3079,9 +3382,11 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); let projection = runtime .load_farm_rules_projection() @@ -3110,9 +3415,11 @@ mod tests { fn saving_farm_rules_projection_refreshes_saved_farm_summary_and_pickup_defaults() { let runtime = memory_runtime(); - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -3149,9 +3456,11 @@ mod tests { .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); let default_pickup_location_id = PickupLocationId::new(); let market_pickup_location_id = PickupLocationId::new(); @@ -3284,9 +3593,11 @@ mod tests { fn runtime_reset_local_device_state_clears_store_file_and_projection() { let (runtime, paths) = file_backed_runtime("reset"); - assert!(runtime - .generate_local_account(Some("First".to_owned())) - .expect("first account should generate")); + assert!( + runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate") + ); let first_account_id = runtime .summary() .settings_account_projection @@ -3296,9 +3607,11 @@ mod tests { .account .account_id .clone(); - assert!(runtime - .generate_local_account(Some("Second".to_owned())) - .expect("second account should generate")); + assert!( + runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate") + ); let second_account_id = runtime .summary() .settings_account_projection @@ -3322,17 +3635,21 @@ mod tests { ); assert!(paths.store_path.exists()); - assert!(runtime - .reset_local_device_state() - .expect("device state should reset")); + assert!( + runtime + .reset_local_device_state() + .expect("device state should reset") + ); let summary = runtime.summary(); assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); assert!(summary.settings_account_projection.roster.is_empty()); - assert!(summary - .settings_account_projection - .selected_account - .is_none()); + assert!( + summary + .settings_account_projection + .selected_account + .is_none() + ); assert!(!paths.store_path.exists()); assert_eq!( runtime @@ -3631,9 +3948,11 @@ mod tests { } fn provision_ready_farmer_account(runtime: &DesktopAppRuntime) -> (String, FarmId) { - assert!(runtime - .generate_local_account(Some("Farmer".to_owned())) - .expect("account should generate")); + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); let account_id = runtime .summary() .settings_account_projection @@ -3669,9 +3988,11 @@ mod tests { .expect("sqlite store") .save_farm_setup(account_id.as_str(), &farm_setup_projection) .expect("farm setup should save"); - assert!(runtime - .select_local_account(account_id.as_str()) - .expect("account should select")); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); (account_id, farm_id) } diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -35,10 +35,15 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", "failed to add relay `{relay_url}`: {error}", "failed to load farm settings projection", + "failed to mark order completed", + "failed to mark order packed", "failed to open existing product editor", "failed to open new product editor", + "failed to open order detail", + "failed to route into orders view", "failed to save farm settings projection", "failed to save product editor draft", + "failed to update orders filter", "failed to route into products view", "failed to update product stock", "failed to update products filter", @@ -54,10 +59,13 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "home-farm-setup-shipping", "home-farm-setup-start", "home-generate-key", + "home-nav-orders", "home-nav-products", "home-nav-today", + "home-orders-scroll", "home-signer-back", "home-signer-source-input", + "home-today-open-orders", "home-today-open-products-drafts", "home-today-open-products-low-stock", "home-products-scroll", @@ -66,6 +74,24 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "identity", "none", "npub1", + "orders", + "orders-detail-mark-completed", + "orders-detail-mark-packed", + "orders-filter-all", + "orders-filter-completed", + "orders-filter-needs-action", + "orders-filter-packed", + "orders-filter-refunded", + "orders-filter-scheduled", + "orders-row-action-mark-completed", + "orders-row-action-mark-packed", + "orders-row-action-review", + "orders-row-open", + "orders.detail_open_failed", + "orders.filter_update_failed", + "orders.mark_completed_failed", + "orders.mark_packed_failed", + "orders.route_failed", "preview", "products", "products-filter-all", @@ -170,6 +196,37 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeTodayOpenInProductsAction", "AppTextKey::HomeNavToday", "AppTextKey::HomeNavProducts", + "AppTextKey::HomeNavOrders", + "AppTextKey::HomeTodayOpenInOrdersAction", + "AppTextKey::OrdersTitle", + "AppTextKey::OrdersFiltersTitle", + "AppTextKey::OrdersSummaryTotal", + "AppTextKey::OrdersFilterAll", + "AppTextKey::OrdersStatusNeedsAction", + "AppTextKey::OrdersStatusScheduled", + "AppTextKey::OrdersStatusPacked", + "AppTextKey::OrdersStatusCompleted", + "AppTextKey::OrdersStatusRefunded", + "AppTextKey::OrdersTableTitle", + "AppTextKey::OrdersColumnOrder", + "AppTextKey::OrdersColumnStatus", + "AppTextKey::OrdersColumnWindow", + "AppTextKey::OrdersColumnPickup", + "AppTextKey::OrdersColumnAction", + "AppTextKey::OrdersActionReview", + "AppTextKey::OrdersActionMarkPacked", + "AppTextKey::OrdersActionMarkCompleted", + "AppTextKey::OrdersEmptyTitle", + "AppTextKey::OrdersEmptyBody", + "AppTextKey::OrdersEmptyNeedsActionTitle", + "AppTextKey::OrdersEmptyNeedsActionBody", + "AppTextKey::OrdersDetailTitle", + "AppTextKey::OrdersDetailEmptyBody", + "AppTextKey::OrdersDetailItemsTitle", + "AppTextKey::OrdersDetailCustomerLabel", + "AppTextKey::OrdersDetailStatusLabel", + "AppTextKey::OrdersDetailWindowLabel", + "AppTextKey::OrdersDetailPickupLabel", "AppTextKey::ProductsTitle", "AppTextKey::ProductsFiltersTitle", "AppTextKey::ProductsSearchPlaceholder", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -15,10 +15,11 @@ use radroots_app_models::{ FarmOrderMethod, FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, - LoggedOutStartupPhase, OrderListRow, PickupLocationId, PickupLocationRecord, - ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, - ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, - TodayAgendaProjection, TodaySetupTaskKind, + LoggedOutStartupPhase, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, + OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListRow, PickupLocationId, + PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, + ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, + ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -931,6 +932,93 @@ impl HomeView { } } + fn open_orders(&mut self, cx: &mut Context<Self>) { + match self.runtime.open_orders() { + Ok(true) => { + self.products_stock_editor = None; + self.product_editor_form = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.route_failed", + error = %runtime_error, + "failed to route into orders view" + ); + } + } + } + + fn select_orders_filter(&mut self, filter: OrdersFilter, cx: &mut Context<Self>) { + match self.runtime.select_orders_filter(filter) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.filter_update_failed", + error = %runtime_error, + filter = filter.storage_key(), + "failed to update orders filter" + ); + } + } + } + + fn open_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.open_order_detail(order_id) { + Ok(true) => { + self.products_stock_editor = None; + self.product_editor_form = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.detail_open_failed", + error = %runtime_error, + order_id = %order_id, + "failed to open order detail" + ); + } + } + } + + fn mark_order_packed(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.mark_order_packed(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.mark_packed_failed", + error = %runtime_error, + order_id = %order_id, + "failed to mark order packed" + ); + } + } + } + + fn mark_order_completed(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.mark_order_completed(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.mark_completed_failed", + error = %runtime_error, + order_id = %order_id, + "failed to mark order completed" + ); + } + } + } + fn open_products_stock_editor( &mut self, product_id: ProductId, @@ -1256,6 +1344,9 @@ impl HomeView { FarmerSection::Products if farmer_products_available(runtime) => { self.render_products_content(runtime, cx) } + FarmerSection::Orders if farmer_products_available(runtime) => { + self.render_orders_content(runtime, cx) + } FarmerSection::Today | FarmerSection::Products | FarmerSection::Orders @@ -1281,6 +1372,7 @@ impl HomeView { }), cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)), cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)), + cx.listener(|this, _, _, cx| this.open_orders(cx)), cx.listener(|this, _, _, cx| { this.open_products_filter(ProductsFilter::NeedAttention, cx) }), @@ -1297,6 +1389,7 @@ impl HomeView { cx.listener(|this, _, _, cx| { this.select_farmer_section(FarmerSection::Products, cx) }), + cx.listener(|this, _, _, cx| this.open_orders(cx)), cx, ) .into_any_element(), @@ -1410,6 +1503,126 @@ impl HomeView { .into_any_element() } + fn render_orders_content( + &mut self, + runtime: &DesktopAppRuntimeSummary, + cx: &mut Context<Self>, + ) -> AnyElement { + let projection = &runtime.orders_projection; + let summary = &projection.list.summary; + + app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) + .w_full() + .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) + .mx_auto() + .child(app_text_value(app_shared_text(AppTextKey::OrdersTitle))) + .child( + div() + .w_full() + .flex() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child(home_summary_metric( + AppTextKey::OrdersSummaryTotal, + summary.total_orders, + )) + .child(home_summary_metric( + AppTextKey::OrdersStatusNeedsAction, + summary.needs_action_orders, + )) + .child(home_summary_metric( + AppTextKey::OrdersStatusScheduled, + summary.scheduled_orders, + )) + .child(home_summary_metric( + AppTextKey::OrdersStatusPacked, + summary.packed_orders, + )), + ) + .child(home_card( + app_shared_text(AppTextKey::OrdersFiltersTitle), + app_cluster(APP_UI_THEME.foundation.spacing.tight_px) + .child(choice_button( + "orders-filter-all", + app_shared_text(AppTextKey::OrdersFilterAll), + projection.query.filter == OrdersFilter::All, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::All, cx) + }), + cx, + )) + .child(choice_button( + "orders-filter-needs-action", + app_shared_text(AppTextKey::OrdersStatusNeedsAction), + projection.query.filter == OrdersFilter::NeedsAction, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::NeedsAction, cx) + }), + cx, + )) + .child(choice_button( + "orders-filter-scheduled", + app_shared_text(AppTextKey::OrdersStatusScheduled), + projection.query.filter == OrdersFilter::Scheduled, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::Scheduled, cx) + }), + cx, + )) + .child(choice_button( + "orders-filter-packed", + app_shared_text(AppTextKey::OrdersStatusPacked), + projection.query.filter == OrdersFilter::Packed, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::Packed, cx) + }), + cx, + )) + .child(choice_button( + "orders-filter-completed", + app_shared_text(AppTextKey::OrdersStatusCompleted), + projection.query.filter == OrdersFilter::Completed, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::Completed, cx) + }), + cx, + )) + .child(choice_button( + "orders-filter-refunded", + app_shared_text(AppTextKey::OrdersStatusRefunded), + projection.query.filter == OrdersFilter::Refunded, + cx.listener(|this, _, _, cx| { + this.select_orders_filter(OrdersFilter::Refunded, cx) + }), + cx, + )), + )) + .child(if projection.list.is_empty() { + orders_empty_state_card(projection.query.filter).into_any_element() + } else { + self.render_orders_table_card( + &projection.list.rows, + projection.detail.as_ref().map(|detail| detail.order_id), + cx, + ) + }) + .when_some(projection.detail.as_ref(), |this, detail| { + this.child(self.render_order_detail_card(detail, cx)) + }) + .when( + projection.detail.is_none() && !projection.list.is_empty(), + |this| { + this.child( + home_card( + app_shared_text(AppTextKey::OrdersDetailTitle), + home_body_text(app_shared_text(AppTextKey::OrdersDetailEmptyBody)), + ) + .into_any_element(), + ) + }, + ) + .into_any_element() + } + fn render_products_table_card( &mut self, rows: &[ProductsListRow], @@ -1437,6 +1650,115 @@ impl HomeView { .into_any_element() } + fn render_orders_table_card( + &mut self, + rows: &[OrdersListRow], + selected_order_id: Option<OrderId>, + cx: &mut Context<Self>, + ) -> AnyElement { + let mut items = Vec::with_capacity(rows.len().saturating_mul(2)); + for (index, row) in rows.iter().enumerate() { + items.push(self.render_orders_table_entry(index, row, selected_order_id, cx)); + if index + 1 < rows.len() { + items.push(section_divider().into_any_element()); + } + } + + home_card( + app_shared_text(AppTextKey::OrdersTableTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(12.0)) + .child(orders_table_header()) + .child(section_divider()) + .children(items), + ) + .into_any_element() + } + + fn render_order_detail_card( + &mut self, + detail: &OrderDetailProjection, + cx: &mut Context<Self>, + ) -> AnyElement { + let primary_action = match detail.primary_action { + Some(OrderPrimaryAction::MarkPacked) => Some( + action_button_primary( + "orders-detail-mark-packed", + app_shared_text(AppTextKey::OrdersActionMarkPacked), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| this.mark_order_packed(order_id, cx) + }), + cx, + ) + .into_any_element(), + ), + Some(OrderPrimaryAction::MarkCompleted) => Some( + action_button_primary( + "orders-detail-mark-completed", + app_shared_text(AppTextKey::OrdersActionMarkCompleted), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| this.mark_order_completed(order_id, cx) + }), + cx, + ) + .into_any_element(), + ), + Some(OrderPrimaryAction::Review) | None => None, + }; + + home_card( + app_shared_text(AppTextKey::OrdersDetailTitle), + 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(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()), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::OrdersDetailPickupLabel), + order_optional_text(detail.pickup_location_label.as_deref()), + ), + ])) + .child(app_form_section( + app_shared_text(AppTextKey::OrdersDetailItemsTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .children( + detail + .items + .iter() + .map(order_detail_item_row) + .collect::<Vec<_>>(), + ) + .when(detail.items.is_empty(), |this| { + this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) + }), + )) + .when_some(primary_action, |this, primary_action| { + this.child(div().child(primary_action)) + }), + ) + .into_any_element() + } + fn render_products_table_entry( &mut self, index: usize, @@ -1508,6 +1830,50 @@ impl HomeView { }) .into_any_element() } + + fn render_orders_table_entry( + &mut self, + index: usize, + row: &OrdersListRow, + selected_order_id: Option<OrderId>, + cx: &mut Context<Self>, + ) -> AnyElement { + let is_selected = selected_order_id.is_some_and(|order_id| order_id == row.order_id); + let order = list_row_button( + ("orders-row-open", index), + row.order_number.clone(), + Some(SharedString::from(row.customer_display_name.clone())), + is_selected, + cx.listener({ + let order_id = row.order_id; + move |this, _, _, cx| this.open_order_detail(order_id, cx) + }), + cx, + ) + .into_any_element(); + let action = orders_table_action( + index, + row, + cx.listener({ + let order_id = row.order_id; + move |this, _, _, cx| this.open_order_detail(order_id, cx) + }), + cx.listener({ + let order_id = row.order_id; + move |this, _, _, cx| this.mark_order_packed(order_id, cx) + }), + cx.listener({ + let order_id = row.order_id; + move |this, _, _, cx| this.mark_order_completed(order_id, cx) + }), + cx, + ); + + div() + .w_full() + .child(orders_table_row(order, row, action)) + .into_any_element() + } } impl Render for HomeView { @@ -4545,10 +4911,11 @@ fn home_sidebar( runtime: &DesktopAppRuntimeSummary, on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_select_orders: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { let selected_section = selected_farmer_section(runtime); - let products_available = farmer_products_available(runtime); + let workspace_available = farmer_products_available(runtime); app_surface_sidebar( div() @@ -4572,7 +4939,7 @@ fn home_sidebar( on_select_today, cx, )) - .when(products_available, |this| { + .when(workspace_available, |this| { this.child(home_sidebar_nav_button( "home-nav-products", AppTextKey::HomeNavProducts, @@ -4580,6 +4947,13 @@ fn home_sidebar( on_select_products, cx, )) + .child(home_sidebar_nav_button( + "home-nav-orders", + AppTextKey::HomeNavOrders, + selected_section == FarmerSection::Orders, + on_select_orders, + cx, + )) }), ) .child( @@ -4619,6 +4993,7 @@ fn home_today_content( farm_setup_form: Option<AnyElement>, on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_open_orders: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_open_low_stock_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_open_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, @@ -4684,7 +5059,15 @@ fn home_today_content( .iter() .map(home_order_row) .collect::<Vec<_>>(), - None, + Some( + action_button_compact( + "home-today-open-orders", + app_shared_text(AppTextKey::HomeTodayOpenInOrdersAction), + on_open_orders, + cx, + ) + .into_any_element(), + ), ) .into_any_element(), ); @@ -4822,10 +5205,8 @@ fn farmer_products_available(runtime: &DesktopAppRuntimeSummary) -> bool { fn home_content_scroll_id(section: FarmerSection) -> &'static str { match section { FarmerSection::Products => "home-products-scroll", - FarmerSection::Today - | FarmerSection::Orders - | FarmerSection::PackDay - | FarmerSection::Farm => "home-today-scroll", + FarmerSection::Orders => "home-orders-scroll", + FarmerSection::Today | FarmerSection::PackDay | FarmerSection::Farm => "home-today-scroll", } } @@ -5138,6 +5519,156 @@ fn products_table_row( .child(div().w(px(120.0)).flex().justify_end().child(action)) } +fn orders_table_header() -> impl IntoElement { + div() + .w_full() + .flex() + .items_center() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child(products_table_header_column( + AppTextKey::OrdersColumnOrder, + None, + true, + )) + .child(products_table_header_column( + AppTextKey::OrdersColumnStatus, + Some(128.0), + false, + )) + .child(products_table_header_column( + AppTextKey::OrdersColumnWindow, + Some(196.0), + false, + )) + .child(products_table_header_column( + AppTextKey::OrdersColumnPickup, + Some(196.0), + false, + )) + .child(products_table_header_column( + AppTextKey::OrdersColumnAction, + Some(132.0), + false, + )) +} + +fn orders_table_row( + order: AnyElement, + row: &OrdersListRow, + action: AnyElement, +) -> impl IntoElement { + div() + .w_full() + .flex() + .items_center() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child(order) + .child( + div() + .w(px(128.0)) + .flex() + .items_center() + .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( + div() + .w(px(196.0)) + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(order_optional_text(row.fulfillment_window_label.as_deref())), + ) + .child( + div() + .w(px(196.0)) + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(order_optional_text(row.pickup_location_label.as_deref())), + ) + .child(div().w(px(132.0)).flex().justify_end().child(action)) +} + +fn orders_table_action( + index: usize, + row: &OrdersListRow, + on_review: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_mark_packed: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + on_mark_completed: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + match row.primary_action { + Some(OrderPrimaryAction::Review) => action_button_compact( + ("orders-row-action-review", index), + app_shared_text(AppTextKey::OrdersActionReview), + on_review, + cx, + ) + .into_any_element(), + Some(OrderPrimaryAction::MarkPacked) => action_button_compact( + ("orders-row-action-mark-packed", index), + app_shared_text(AppTextKey::OrdersActionMarkPacked), + on_mark_packed, + cx, + ) + .into_any_element(), + Some(OrderPrimaryAction::MarkCompleted) => action_button_compact( + ("orders-row-action-mark-completed", index), + app_shared_text(AppTextKey::OrdersActionMarkCompleted), + on_mark_completed, + cx, + ) + .into_any_element(), + None => div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(app_shared_text(AppTextKey::ValueNone)) + .into_any_element(), + } +} + +fn orders_empty_state_card(filter: OrdersFilter) -> impl IntoElement { + let (title_key, body_key) = if filter == OrdersFilter::NeedsAction { + ( + AppTextKey::OrdersEmptyNeedsActionTitle, + AppTextKey::OrdersEmptyNeedsActionBody, + ) + } else { + (AppTextKey::OrdersEmptyTitle, AppTextKey::OrdersEmptyBody) + }; + + 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::Refunded => AppTextKey::OrdersStatusRefunded, + } +} + +fn orders_status_color(status: OrderStatus) -> u32 { + match status { + OrderStatus::NeedsAction => APP_UI_THEME.components.app_status_indicator.attention, + OrderStatus::Scheduled | OrderStatus::Packed => { + APP_UI_THEME.components.app_status_indicator.online + } + OrderStatus::Completed | OrderStatus::Refunded => { + APP_UI_THEME.components.app_status_indicator.offline + } + } +} + fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement { let (title_key, body_key) = if filter == ProductsFilter::NeedAttention { ( @@ -6422,6 +6953,40 @@ fn home_list_card( ) } +fn order_detail_item_row(item: &OrderDetailItemRow) -> AnyElement { + div() + .w_full() + .min_w_0() + .flex() + .items_center() + .justify_between() + .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(item.title.clone()), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(item.quantity_display.clone()), + ) + .into_any_element() +} + +fn order_optional_text(value: Option<&str>) -> SharedString { + value + .filter(|value| !value.trim().is_empty()) + .map(|value| SharedString::from(value.to_owned())) + .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone)) +} + fn home_order_row(order: &OrderListRow) -> AnyElement { div() .w_full() diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -24,6 +24,7 @@ define_app_text_keys! { HomeBrand => "home.brand", HomeNavToday => "home.nav.today", HomeNavProducts => "home.nav.products", + HomeNavOrders => "home.nav.orders", HomeTodayTitle => "home.today.title", HomeTodayStatusNoFarm => "home.today.status.no_farm", HomeTodayStatusSetup => "home.today.status.setup", @@ -33,6 +34,7 @@ define_app_text_keys! { HomeTodayOrdersNeedingAction => "home.today.orders_needing_action", HomeTodayLowStock => "home.today.low_stock", HomeTodayDraftProducts => "home.today.draft_products", + HomeTodayOpenInOrdersAction => "home.today.open_in_orders.action", HomeTodayOpenInProductsAction => "home.today.open_in_products.action", HomeTodaySetupChecklist => "home.today.setup_checklist", HomeTodayNextFulfillmentWindow => "home.today.next_fulfillment_window", @@ -89,6 +91,35 @@ define_app_text_keys! { HomeTodayEmptyNoFarmBody => "home.today.empty.no_farm.body", HomeTodayEmptyQuietTitle => "home.today.empty.quiet.title", HomeTodayEmptyQuietBody => "home.today.empty.quiet.body", + OrdersTitle => "orders.title", + OrdersFiltersTitle => "orders.filters.title", + OrdersSummaryTotal => "orders.summary.total", + OrdersFilterAll => "orders.filter.all", + OrdersStatusNeedsAction => "orders.status.needs_action", + OrdersStatusScheduled => "orders.status.scheduled", + OrdersStatusPacked => "orders.status.packed", + OrdersStatusCompleted => "orders.status.completed", + OrdersStatusRefunded => "orders.status.refunded", + OrdersTableTitle => "orders.table.title", + OrdersColumnOrder => "orders.column.order", + OrdersColumnStatus => "orders.column.status", + OrdersColumnWindow => "orders.column.window", + OrdersColumnPickup => "orders.column.pickup", + OrdersColumnAction => "orders.column.action", + OrdersActionReview => "orders.action.review", + OrdersActionMarkPacked => "orders.action.mark_packed", + OrdersActionMarkCompleted => "orders.action.mark_completed", + OrdersEmptyTitle => "orders.empty.title", + OrdersEmptyBody => "orders.empty.body", + OrdersEmptyNeedsActionTitle => "orders.empty.needs_action.title", + OrdersEmptyNeedsActionBody => "orders.empty.needs_action.body", + OrdersDetailTitle => "orders.detail.title", + OrdersDetailEmptyBody => "orders.detail.empty.body", + OrdersDetailItemsTitle => "orders.detail.items.title", + OrdersDetailCustomerLabel => "orders.detail.customer.label", + OrdersDetailStatusLabel => "orders.detail.status.label", + OrdersDetailWindowLabel => "orders.detail.window.label", + OrdersDetailPickupLabel => "orders.detail.pickup.label", ProductsTitle => "products.title", ProductsFiltersTitle => "products.filters.title", ProductsSearchPlaceholder => "products.search.placeholder", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -168,6 +168,26 @@ mod tests { } #[test] + fn english_orders_copy_matches_the_queue_contract() { + assert_eq!(app_text(AppTextKey::HomeNavOrders), "Orders"); + assert_eq!( + app_text(AppTextKey::HomeTodayOpenInOrdersAction), + "Open in Orders" + ); + assert_eq!(app_text(AppTextKey::OrdersTitle), "Orders"); + assert_eq!( + app_text(AppTextKey::OrdersStatusNeedsAction), + "Needs action" + ); + assert_eq!(app_text(AppTextKey::OrdersActionMarkPacked), "Mark packed"); + assert_eq!( + app_text(AppTextKey::OrdersActionMarkCompleted), + "Mark completed" + ); + assert_eq!(app_text(AppTextKey::OrdersDetailTitle), "Order detail"); + } + + #[test] fn english_farm_rules_host_copy_matches_the_frozen_utility_window_inventory() { assert_eq!(app_text(AppTextKey::SettingsNavFarm), "Farm"); assert_eq!( diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -3,6 +3,7 @@ "home.brand": "radroots", "home.nav.today": "Today", "home.nav.products": "Products", + "home.nav.orders": "Orders", "home.today.title": "Today", "home.today.status.no_farm": "No farm configured", "home.today.status.setup": "Setup required", @@ -12,6 +13,7 @@ "home.today.orders_needing_action": "Orders needing action", "home.today.low_stock": "Low stock", "home.today.draft_products": "Draft products", + "home.today.open_in_orders.action": "Open in Orders", "home.today.open_in_products.action": "Open in Products", "home.today.setup_checklist": "Setup checklist", "home.today.next_fulfillment_window": "Next fulfillment window", @@ -68,6 +70,35 @@ "home.today.empty.no_farm.body": "Create a farm to start using the farmer workspace.", "home.today.empty.quiet.title": "Nothing urgent right now", "home.today.empty.quiet.body": "Orders, stock, and drafts will appear here when they need attention.", + "orders.title": "Orders", + "orders.filters.title": "View", + "orders.summary.total": "Total orders", + "orders.filter.all": "All", + "orders.status.needs_action": "Needs action", + "orders.status.scheduled": "Scheduled", + "orders.status.packed": "Packed", + "orders.status.completed": "Completed", + "orders.status.refunded": "Refunded", + "orders.table.title": "Order queue", + "orders.column.order": "Order", + "orders.column.status": "Status", + "orders.column.window": "Window", + "orders.column.pickup": "Pickup", + "orders.column.action": "Action", + "orders.action.review": "Review", + "orders.action.mark_packed": "Mark packed", + "orders.action.mark_completed": "Mark completed", + "orders.empty.title": "No orders yet", + "orders.empty.body": "Orders will appear here when customers place them.", + "orders.empty.needs_action.title": "Nothing needs action", + "orders.empty.needs_action.body": "Current orders are already scheduled or packed.", + "orders.detail.title": "Order detail", + "orders.detail.empty.body": "Select an order to review the items and next step.", + "orders.detail.items.title": "Items", + "orders.detail.customer.label": "Customer", + "orders.detail.status.label": "Status", + "orders.detail.window.label": "Fulfillment window", + "orders.detail.pickup.label": "Pickup location", "products.title": "Products", "products.filters.title": "View", "products.search.placeholder": "Search products",