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:
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",