app

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

commit 5de72ac3c622bb684d2ed205b9f204705cfcb19f
parent a6ec9e0ec6ba119397a0eee6888344d6b520c97f
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 00:50:29 +0000

orders: add seller recovery follow-up workflows

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 251++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/launchers/desktop/src/source_guards.rs | 25+++++++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/i18n/src/keys.rs | 13+++++++++++++
Mcrates/shared/i18n/src/lib.rs | 17+++++++++++++++++
Mcrates/shared/models/src/lib.rs | 60++++++++++++++++++++++++++++++++----------------------------
Mcrates/shared/sqlite/src/orders.rs | 2+-
Mcrates/shared/state/src/lib.rs | 2+-
Mi18n/locales/en/messages.json | 13+++++++++++++
9 files changed, 683 insertions(+), 52 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -18,10 +18,10 @@ use radroots_app_models::{ OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, - RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, - ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, - ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, + RecoveryRecordId, RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, + ReminderFeedProjection, ReminderId, ReminderKind, ReminderLogEntryProjection, + ReminderLogProjection, ReminderSurface, ReminderUrgency, SettingsAccountProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -356,6 +356,38 @@ impl DesktopAppRuntime { self.lock_state_mut().mark_order_completed(order_id) } + pub fn start_order_recovery( + &self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().start_order_recovery(order_id, kind) + } + + pub fn review_order_recovery( + &self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().review_order_recovery(order_id, kind) + } + + pub fn reopen_order_recovery( + &self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().reopen_order_recovery(order_id, kind) + } + + pub fn resolve_order_recovery( + &self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().resolve_order_recovery(order_id, kind) + } + pub fn open_pack_day( &self, fulfillment_window_id: Option<FulfillmentWindowId>, @@ -699,7 +731,7 @@ struct DesktopSellerReminderContext { orders_feed: ReminderFeedProjection, pack_day_feed: ReminderFeedProjection, recovery_queue: RecoveryQueueProjection, - selected_order_recovery: Option<OrderRecoveryProjection>, + selected_order_recoveries: Vec<OrderRecoveryProjection>, due_soon_count: u32, recovery_actions_open: u32, reminder_log: ReminderLogProjection, @@ -1527,6 +1559,117 @@ impl DesktopAppRuntimeState { Ok(updated || context_changed || pending_changed) } + fn start_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.upsert_order_recovery(order_id, kind, RecoveryState::Open, "start_order_recovery") + } + + fn review_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.upsert_order_recovery( + order_id, + kind, + RecoveryState::InReview, + "review_order_recovery", + ) + } + + fn reopen_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.upsert_order_recovery(order_id, kind, RecoveryState::Open, "reopen_order_recovery") + } + + fn resolve_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + ) -> Result<bool, AppSqliteError> { + self.upsert_order_recovery( + order_id, + kind, + RecoveryState::Resolved, + "resolve_order_recovery", + ) + } + + fn upsert_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + state: RecoveryState, + source: &str, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(selected_account) = self + .state_store + .identity_projection() + .selected_account + .as_ref() + else { + return Ok(false); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + let Some(_) = sqlite_store.load_order_detail(farm_id, order_id)? else { + return Ok(false); + }; + + let account_id = selected_account.account.account_id.as_str(); + let last_updated_at = current_utc_timestamp(); + let summary = order_recovery_summary(kind, state).to_owned(); + let note = Some(order_recovery_note(kind, state).to_owned()); + let mut record = sqlite_store + .load_recovery_record(account_id, order_id, kind)? + .unwrap_or(OrderRecoveryProjection { + recovery_record_id: RecoveryRecordId::new(), + order_id, + kind, + state, + summary: summary.clone(), + note: note.clone(), + last_updated_at: last_updated_at.clone(), + }); + + if record.state == state && record.summary == summary && record.note == note { + return Ok(false); + } + + record.state = state; + record.summary = summary; + record.note = note; + record.last_updated_at = last_updated_at; + sqlite_store.save_recovery_record(account_id, farm_id, &record)?; + + 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().or(Some(order_id)), + self.state_store.pack_day_projection().query.clone(), + )?; + let context_changed = self.apply_selected_account_context(&selected_account_context); + let pending_changed = + self.enqueue_selected_account_sync_operations(vec![pending_sync_upsert( + SyncAggregateRef::Order(order_id), + order_recovery_sync_payload(order_id, farm_id, kind, state, source), + )])?; + + Ok(context_changed || pending_changed) + } + fn open_pack_day( &mut self, fulfillment_window_id: Option<FulfillmentWindowId>, @@ -2994,7 +3137,7 @@ fn load_selected_account_context_with_options( summary.recovery_actions_open = reminder_context.recovery_actions_open; } if let Some(detail) = order_detail.as_mut() { - detail.recovery = reminder_context.selected_order_recovery; + detail.recoveries = reminder_context.selected_order_recoveries; } pack_day_projection.reminders = reminder_context.pack_day_feed; @@ -3086,13 +3229,9 @@ fn load_selected_account_reminder_context_with_options( } let reminder_log = sqlite_store.load_reminder_log(account_id, farm_id, 8)?; - let selected_order_recovery = selected_order_detail.and_then(|detail| { - recovery_queue - .items - .iter() - .find(|record| record.order_id == detail.order_id) - .cloned() - }); + let selected_order_recoveries = selected_order_detail + .map(|detail| ordered_order_recoveries_for_detail(&recovery_queue, detail.order_id)) + .unwrap_or_default(); let due_soon_count = schedule .items .iter() @@ -3117,7 +3256,7 @@ fn load_selected_account_reminder_context_with_options( orders_feed: filter_reminder_surface(&schedule, ReminderSurface::Orders), pack_day_feed: filter_reminder_surface(&schedule, ReminderSurface::PackDay), recovery_queue, - selected_order_recovery, + selected_order_recoveries, due_soon_count, recovery_actions_open, reminder_log, @@ -3551,6 +3690,88 @@ fn build_reminder_log_entry( } } +fn ordered_order_recoveries_for_detail( + recovery_queue: &RecoveryQueueProjection, + order_id: OrderId, +) -> Vec<OrderRecoveryProjection> { + let mut items = recovery_queue + .items + .iter() + .filter(|record| record.order_id == order_id) + .cloned() + .collect::<Vec<_>>(); + items.sort_by(|left, right| { + order_recovery_kind_rank(left.kind) + .cmp(&order_recovery_kind_rank(right.kind)) + .then_with(|| right.last_updated_at.cmp(&left.last_updated_at)) + .then_with(|| left.recovery_record_id.cmp(&right.recovery_record_id)) + }); + items +} + +fn order_recovery_kind_rank(kind: RecoveryKind) -> u8 { + match kind { + RecoveryKind::MissedPickup => 0, + RecoveryKind::RefundFollowUp => 1, + } +} + +fn order_recovery_summary(kind: RecoveryKind, state: RecoveryState) -> &'static str { + match (kind, state) { + (RecoveryKind::MissedPickup, RecoveryState::Open) => "Missed pickup follow-up is open", + (RecoveryKind::MissedPickup, RecoveryState::InReview) => { + "Missed pickup follow-up is in review" + } + (RecoveryKind::MissedPickup, RecoveryState::Resolved) => { + "Missed pickup follow-up is resolved" + } + (RecoveryKind::RefundFollowUp, RecoveryState::Open) => "Refund follow-up is open", + (RecoveryKind::RefundFollowUp, RecoveryState::InReview) => "Refund follow-up is in review", + (RecoveryKind::RefundFollowUp, RecoveryState::Resolved) => "Refund follow-up is resolved", + } +} + +fn order_recovery_note(kind: RecoveryKind, state: RecoveryState) -> &'static str { + match (kind, state) { + (RecoveryKind::MissedPickup, RecoveryState::Open) => { + "Check in with the buyer and agree on the next step." + } + (RecoveryKind::MissedPickup, RecoveryState::InReview) => { + "Use notes outside the app to confirm a new pickup or another resolution." + } + (RecoveryKind::MissedPickup, RecoveryState::Resolved) => { + "The seller and buyer have agreed on the next step." + } + (RecoveryKind::RefundFollowUp, RecoveryState::Open) => { + "Review the situation and handle any refund outside the app." + } + (RecoveryKind::RefundFollowUp, RecoveryState::InReview) => { + "Confirm the outcome and keep payment handling outside the app." + } + (RecoveryKind::RefundFollowUp, RecoveryState::Resolved) => { + "The refund follow-up was handled outside the app." + } + } +} + +fn order_recovery_sync_payload( + order_id: OrderId, + farm_id: FarmId, + kind: RecoveryKind, + state: RecoveryState, + source: &str, +) -> String { + json!({ + "aggregate_kind": "order_recovery", + "order_id": order_id.to_string(), + "farm_id": farm_id.to_string(), + "recovery_kind": kind.storage_key(), + "recovery_state": state.storage_key(), + "source": source, + }) + .to_string() +} + fn load_selected_account_sync_context( sqlite_store: &AppSqliteStore, identity_projection: &AppIdentityProjection, @@ -6396,7 +6617,7 @@ mod tests { .orders_projection .detail .as_ref() - .and_then(|detail| detail.recovery.as_ref()) + .and_then(|detail| detail.recoveries.first()) .expect("order recovery") .kind, RecoveryKind::MissedPickup diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -82,6 +82,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to switch into farm mode", "failed to switch into marketplace mode", "failed to update orders filter", + "failed to start order recovery", + "failed to review order recovery", + "failed to reopen order recovery", + "failed to resolve order recovery", "failed to route into products view", "failed to update product stock", "failed to update products filter", @@ -145,6 +149,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "orders-filter-packed", "orders-filter-refunded", "orders-filter-scheduled", + "orders-recovery-open", + "orders-recovery-review", + "orders-recovery-reopen", + "orders-recovery-resolve", "orders-row-action-mark-completed", "orders-row-action-mark-packed", "orders-row-action-review", @@ -153,6 +161,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "orders.filter_update_failed", "orders.mark_completed_failed", "orders.mark_packed_failed", + "orders.recovery_reopen_failed", + "orders.recovery_resolve_failed", + "orders.recovery_review_failed", + "orders.recovery_start_failed", "orders.route_failed", "preview", "products", @@ -372,6 +384,19 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::OrdersDetailStatusLabel", "AppTextKey::OrdersDetailWindowLabel", "AppTextKey::OrdersDetailPickupLabel", + "AppTextKey::OrdersRecoverySectionTitle", + "AppTextKey::OrdersRecoveryMissedPickupTitle", + "AppTextKey::OrdersRecoveryMissedPickupBody", + "AppTextKey::OrdersRecoveryRefundFollowUpTitle", + "AppTextKey::OrdersRecoveryRefundFollowUpBody", + "AppTextKey::OrdersRecoveryLastUpdatedLabel", + "AppTextKey::OrdersRecoveryActionOpenFollowUp", + "AppTextKey::OrdersRecoveryActionStartReview", + "AppTextKey::OrdersRecoveryActionMarkOpen", + "AppTextKey::OrdersRecoveryActionResolve", + "AppTextKey::OrdersRecoveryStateOpen", + "AppTextKey::OrdersRecoveryStateInReview", + "AppTextKey::OrdersRecoveryStateResolved", "AppTextKey::OrdersRemindersTitle", "AppTextKey::OrdersReminderLogTitle", "AppTextKey::OrdersReminderLogEmptyBody", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -19,13 +19,13 @@ use radroots_app_models::{ FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, - OrderStatus, OrdersFilter, OrdersListRow, PackDayPackListRow, PackDayProductTotalRow, - PackDayRosterRow, PersonalEntryState, PersonalSection, PickupLocationId, PickupLocationRecord, - ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, - ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, - ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, - ReminderLogProjection, ReminderSurface, ReminderUrgency, ShellSection, TodayAgendaProjection, - TodaySetupTaskKind, + OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListRow, PackDayPackListRow, + PackDayProductTotalRow, PackDayRosterRow, PersonalEntryState, PersonalSection, + PickupLocationId, PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, + ProductListRow, ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductsFilter, + ProductsListRow, ProductsSort, RecoveryKind, RecoveryState, ReminderDeadlineProjection, + ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, + ReminderSurface, ReminderUrgency, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, @@ -1587,6 +1587,94 @@ impl HomeView { } } + fn start_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + cx: &mut Context<Self>, + ) { + match self.runtime.start_order_recovery(order_id, kind) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.recovery_start_failed", + error = %runtime_error, + order_id = %order_id, + recovery_kind = kind.storage_key(), + "failed to start order recovery" + ); + } + } + } + + fn review_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + cx: &mut Context<Self>, + ) { + match self.runtime.review_order_recovery(order_id, kind) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.recovery_review_failed", + error = %runtime_error, + order_id = %order_id, + recovery_kind = kind.storage_key(), + "failed to review order recovery" + ); + } + } + } + + fn reopen_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + cx: &mut Context<Self>, + ) { + match self.runtime.reopen_order_recovery(order_id, kind) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.recovery_reopen_failed", + error = %runtime_error, + order_id = %order_id, + recovery_kind = kind.storage_key(), + "failed to reopen order recovery" + ); + } + } + } + + fn resolve_order_recovery( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + cx: &mut Context<Self>, + ) { + match self.runtime.resolve_order_recovery(order_id, kind) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "orders", + event = "orders.recovery_resolve_failed", + error = %runtime_error, + order_id = %order_id, + recovery_kind = kind.storage_key(), + "failed to resolve order recovery" + ); + } + } + } + fn open_products_stock_editor( &mut self, product_id: ProductId, @@ -3486,6 +3574,7 @@ impl HomeView { this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) }), )) + .child(self.render_order_recovery_section(detail, cx)) .when_some(primary_action, |this, primary_action| { this.child(div().child(primary_action)) }), @@ -3493,6 +3582,209 @@ impl HomeView { .into_any_element() } + fn render_order_recovery_section( + &mut self, + detail: &OrderDetailProjection, + cx: &mut Context<Self>, + ) -> AnyElement { + app_form_section( + app_shared_text(AppTextKey::OrdersRecoverySectionTitle), + div() + .w_full() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) + .child( + self.render_order_recovery_card( + detail.order_id, + RecoveryKind::MissedPickup, + detail + .recoveries + .iter() + .find(|record| record.kind == RecoveryKind::MissedPickup), + cx, + ), + ) + .child( + self.render_order_recovery_card( + detail.order_id, + RecoveryKind::RefundFollowUp, + detail + .recoveries + .iter() + .find(|record| record.kind == RecoveryKind::RefundFollowUp), + cx, + ), + ), + ) + .into_any_element() + } + + fn render_order_recovery_card( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + recovery: Option<&OrderRecoveryProjection>, + cx: &mut Context<Self>, + ) -> AnyElement { + let title_key = order_recovery_title_key(kind); + let body = recovery.map_or_else( + || { + home_body_text(app_shared_text(order_recovery_empty_body_key(kind))) + .into_any_element() + }, + |record| { + app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) + .w_full() + .child(home_body_text(record.summary.clone())) + .when_some( + record + .note + .as_ref() + .map(|note| note.trim()) + .filter(|note| !note.is_empty()), + |this, note| this.child(home_body_text(note.to_owned())), + ) + .child( + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) + .child(format!( + "{}: {}", + app_text(AppTextKey::OrdersRecoveryLastUpdatedLabel), + record.last_updated_at + )), + ) + .into_any_element() + }, + ); + + app_surface_card( + app_stack_v(APP_UI_THEME.foundation.spacing.medium_px) + .w_full() + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_start() + .justify_between() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.foundation.text.primary)) + .child(app_shared_text(title_key)), + ) + .when_some( + recovery.map(|record| order_recovery_state_badge(record.state)), + |this, badge| this.child(badge), + ), + ) + .child(body) + .when_some( + self.render_order_recovery_actions(order_id, kind, recovery, cx), + |this, actions| { + this.child( + div() + .w_full() + .flex() + .items_center() + .justify_end() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child(actions), + ) + }, + ), + ) + .into_any_element() + } + + fn render_order_recovery_actions( + &mut self, + order_id: OrderId, + kind: RecoveryKind, + recovery: Option<&OrderRecoveryProjection>, + cx: &mut Context<Self>, + ) -> Option<AnyElement> { + let index = order_recovery_kind_index(kind); + + match recovery.map(|record| record.state) { + None => Some( + action_button_primary( + ("orders-recovery-open", index), + app_shared_text(AppTextKey::OrdersRecoveryActionOpenFollowUp), + cx.listener(move |this, _, _, cx| { + this.start_order_recovery(order_id, kind, cx) + }), + cx, + ) + .into_any_element(), + ), + Some(RecoveryState::Open) => Some( + div() + .flex() + .items_center() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child(action_button_compact( + ("orders-recovery-review", index), + app_shared_text(AppTextKey::OrdersRecoveryActionStartReview), + cx.listener(move |this, _, _, cx| { + this.review_order_recovery(order_id, kind, cx) + }), + cx, + )) + .child(action_button_primary( + ("orders-recovery-resolve", index), + app_shared_text(AppTextKey::OrdersRecoveryActionResolve), + cx.listener(move |this, _, _, cx| { + this.resolve_order_recovery(order_id, kind, cx) + }), + cx, + )) + .into_any_element(), + ), + Some(RecoveryState::InReview) => Some( + div() + .flex() + .items_center() + .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) + .child(action_button_compact( + ("orders-recovery-reopen", index), + app_shared_text(AppTextKey::OrdersRecoveryActionMarkOpen), + cx.listener(move |this, _, _, cx| { + this.reopen_order_recovery(order_id, kind, cx) + }), + cx, + )) + .child(action_button_primary( + ("orders-recovery-resolve", index), + app_shared_text(AppTextKey::OrdersRecoveryActionResolve), + cx.listener(move |this, _, _, cx| { + this.resolve_order_recovery(order_id, kind, cx) + }), + cx, + )) + .into_any_element(), + ), + Some(RecoveryState::Resolved) => Some( + action_button_compact( + ("orders-recovery-reopen", index), + app_shared_text(AppTextKey::OrdersRecoveryActionMarkOpen), + cx.listener(move |this, _, _, cx| { + this.reopen_order_recovery(order_id, kind, cx) + }), + cx, + ) + .into_any_element(), + ), + } + } + fn render_products_table_entry( &mut self, index: usize, @@ -8777,6 +9069,52 @@ fn orders_status_color(status: OrderStatus) -> u32 { } } +fn order_recovery_title_key(kind: RecoveryKind) -> AppTextKey { + match kind { + RecoveryKind::MissedPickup => AppTextKey::OrdersRecoveryMissedPickupTitle, + RecoveryKind::RefundFollowUp => AppTextKey::OrdersRecoveryRefundFollowUpTitle, + } +} + +fn order_recovery_empty_body_key(kind: RecoveryKind) -> AppTextKey { + match kind { + RecoveryKind::MissedPickup => AppTextKey::OrdersRecoveryMissedPickupBody, + RecoveryKind::RefundFollowUp => AppTextKey::OrdersRecoveryRefundFollowUpBody, + } +} + +fn order_recovery_state_key(state: RecoveryState) -> AppTextKey { + match state { + RecoveryState::Open => AppTextKey::OrdersRecoveryStateOpen, + RecoveryState::InReview => AppTextKey::OrdersRecoveryStateInReview, + RecoveryState::Resolved => AppTextKey::OrdersRecoveryStateResolved, + } +} + +fn order_recovery_state_color(state: RecoveryState) -> u32 { + match state { + RecoveryState::Open => APP_UI_THEME.components.app_status_indicator.attention, + RecoveryState::InReview => APP_UI_THEME.foundation.text.accent, + RecoveryState::Resolved => APP_UI_THEME.components.app_status_indicator.online, + } +} + +fn order_recovery_state_badge(state: RecoveryState) -> AnyElement { + div() + .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(rgb(order_recovery_state_color(state))) + .child(app_shared_text(order_recovery_state_key(state))) + .into_any_element() +} + +fn order_recovery_kind_index(kind: RecoveryKind) -> usize { + match kind { + RecoveryKind::MissedPickup => 0, + RecoveryKind::RefundFollowUp => 1, + } +} + fn pack_day_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { app_stack_v(4.0) .child( diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -181,6 +181,19 @@ define_app_text_keys! { OrdersDetailStatusLabel => "orders.detail.status.label", OrdersDetailWindowLabel => "orders.detail.window.label", OrdersDetailPickupLabel => "orders.detail.pickup.label", + OrdersRecoverySectionTitle => "orders.recovery.section.title", + OrdersRecoveryMissedPickupTitle => "orders.recovery.missed_pickup.title", + OrdersRecoveryMissedPickupBody => "orders.recovery.missed_pickup.body", + OrdersRecoveryRefundFollowUpTitle => "orders.recovery.refund_follow_up.title", + OrdersRecoveryRefundFollowUpBody => "orders.recovery.refund_follow_up.body", + OrdersRecoveryLastUpdatedLabel => "orders.recovery.last_updated.label", + OrdersRecoveryActionOpenFollowUp => "orders.recovery.action.open_follow_up", + OrdersRecoveryActionStartReview => "orders.recovery.action.start_review", + OrdersRecoveryActionMarkOpen => "orders.recovery.action.mark_open", + OrdersRecoveryActionResolve => "orders.recovery.action.resolve", + OrdersRecoveryStateOpen => "orders.recovery.state.open", + OrdersRecoveryStateInReview => "orders.recovery.state.in_review", + OrdersRecoveryStateResolved => "orders.recovery.state.resolved", OrdersRemindersTitle => "orders.reminders.title", OrdersReminderLogTitle => "orders.reminder_log.title", OrdersReminderLogEmptyBody => "orders.reminder_log.empty.body", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -303,6 +303,23 @@ mod tests { "Mark completed" ); assert_eq!(app_text(AppTextKey::OrdersDetailTitle), "Order detail"); + assert_eq!(app_text(AppTextKey::OrdersRecoverySectionTitle), "Recovery"); + assert_eq!( + app_text(AppTextKey::OrdersRecoveryMissedPickupTitle), + "Missed pickup" + ); + assert_eq!( + app_text(AppTextKey::OrdersRecoveryRefundFollowUpTitle), + "Refund follow-up" + ); + assert_eq!( + app_text(AppTextKey::OrdersRecoveryActionResolve), + "Mark resolved" + ); + assert_eq!( + app_text(AppTextKey::OrdersRecoveryStateInReview), + "In review" + ); } #[test] diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1500,7 +1500,7 @@ pub struct OrderDetailProjection { pub pickup_location_label: Option<String>, pub items: Vec<OrderDetailItemRow>, pub primary_action: Option<OrderPrimaryAction>, - pub recovery: Option<OrderRecoveryProjection>, + pub recoveries: Vec<OrderRecoveryProjection>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -1916,9 +1916,7 @@ impl ReminderFeedProjection { .filter(|item| { matches!( item.urgency, - ReminderUrgency::DueSoon - | ReminderUrgency::Overdue - | ReminderUrgency::Blocking + ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking ) }) .count() @@ -2151,24 +2149,24 @@ mod tests { FarmId, FarmOrderMethod, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmTimingConflict, FarmTimingConflictKind, FarmerActivationProjection, - FarmerSection, IdentityBlockedReason, IdentityReadiness, LoggedOutStartupPhase, - LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderListRow, - OrderId, OrderPrimaryAction, OrderRecoveryProjection, OrderStatus, OrdersFilter, - OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, - PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PackDayScreenQueryState, ParseStartupSignerSourceError, PersonalEntryProjection, - PersonalEntryState, PersonalSection, PickupLocationId, ProductAttentionState, - ProductAvailabilityState, ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, - ProductPricePresentation, ProductPublishBlocker, ProductStatus, ProductStockState, - ProductStockSummary, ProductsFilter, ProductsListProjection, ProductsListRow, - ProductsListSummary, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, - RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, - ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, - ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, + FarmerSection, FulfillmentWindowId, IdentityBlockedReason, IdentityReadiness, + LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, + OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, OrderRecoveryProjection, + OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, + OrdersScreenQueryState, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, + PackDayRosterRow, PackDayScreenQueryState, ParseStartupSignerSourceError, + PersonalEntryProjection, PersonalEntryState, PersonalSection, PickupLocationId, + ProductAttentionState, ProductAvailabilityState, ProductAvailabilitySummary, + ProductEditorDraft, ProductListRow, ProductPricePresentation, ProductPublishBlocker, + ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter, + ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, RecoveryKind, + RecoveryQueueProjection, RecoveryRecordId, RecoveryState, ReminderDeadlineProjection, + ReminderDeliveryState, ReminderFeedProjection, ReminderId, ReminderKind, + ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, + RepeatDemandEligibility, RepeatDemandHandoffProjection, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, + StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, - FulfillmentWindowId, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -2799,7 +2797,7 @@ mod tests { quantity_display: "2 bags".to_owned(), }], primary_action: Some(OrderPrimaryAction::MarkPacked), - recovery: None, + recoveries: Vec::new(), }; let pack_day = PackDayProjection { fulfillment_window: Some(super::FulfillmentWindowSummary { @@ -2819,10 +2817,10 @@ mod tests { pickup_roster: vec![PackDayRosterRow { order_id, order_number: "R-1001".to_owned(), - customer_display_name: "Casey".to_owned(), - }], - reminders: ReminderFeedProjection::default(), - }; + customer_display_name: "Casey".to_owned(), + }], + reminders: ReminderFeedProjection::default(), + }; assert!(orders_list.summary.has_orders()); assert!(!orders_list.is_empty()); @@ -3050,13 +3048,19 @@ mod tests { }; assert_eq!(ReminderSurface::PackDay.storage_key(), "pack_day"); - assert_eq!(ReminderKind::RefundRecovery.storage_key(), "refund_recovery"); + assert_eq!( + ReminderKind::RefundRecovery.storage_key(), + "refund_recovery" + ); assert_eq!(ReminderUrgency::DueSoon.storage_key(), "due_soon"); assert_eq!( ReminderDeliveryState::Acknowledged.storage_key(), "acknowledged" ); - assert_eq!(RecoveryKind::RefundFollowUp.storage_key(), "refund_follow_up"); + assert_eq!( + RecoveryKind::RefundFollowUp.storage_key(), + "refund_follow_up" + ); assert_eq!(RecoveryState::InReview.storage_key(), "in_review"); assert_eq!( RepeatDemandEligibility::Unavailable.storage_key(), diff --git a/crates/shared/sqlite/src/orders.rs b/crates/shared/sqlite/src/orders.rs @@ -109,7 +109,7 @@ impl<'a> AppOrdersRepository<'a> { pickup_location_label: empty_string_to_none(pickup_location_label), items, primary_action: primary_action_for_status(status), - recovery: None, + recoveries: Vec::new(), }) }, ) diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -1678,7 +1678,7 @@ mod tests { quantity_display: "2 bags".to_owned(), }], primary_action: Some(OrderPrimaryAction::Review), - recovery: None, + recoveries: Vec::new(), }; let orders_reminders = ReminderFeedProjection { items: vec![radroots_app_models::ReminderDeadlineProjection { diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -161,6 +161,19 @@ "orders.detail.status.label": "Status", "orders.detail.window.label": "Fulfillment window", "orders.detail.pickup.label": "Pickup location", + "orders.recovery.section.title": "Recovery", + "orders.recovery.missed_pickup.title": "Missed pickup", + "orders.recovery.missed_pickup.body": "Use this when a buyer did not collect the order as planned.", + "orders.recovery.refund_follow_up.title": "Refund follow-up", + "orders.recovery.refund_follow_up.body": "Track a refund conversation here. Payment handling stays outside the app.", + "orders.recovery.last_updated.label": "Last updated", + "orders.recovery.action.open_follow_up": "Open follow-up", + "orders.recovery.action.start_review": "Start review", + "orders.recovery.action.mark_open": "Mark open", + "orders.recovery.action.resolve": "Mark resolved", + "orders.recovery.state.open": "Open", + "orders.recovery.state.in_review": "In review", + "orders.recovery.state.resolved": "Resolved", "orders.reminders.title": "Reminders", "orders.reminder_log.title": "Reminder activity", "orders.reminder_log.empty.body": "Recent reminder activity appears here after something needs attention.",