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