Эх сурвалжийг харах

vibecoded bs to check later

UMTS at Teleco 1 сар өмнө
parent
commit
905778bb1d

+ 24 - 0
docs/todo.md

@@ -0,0 +1,24 @@
+# Important :
+
+## Part 2 (Probably needs some changes to the database) : 
+- Add an Asset Tag Generation Field to each item for automatic updates during audits because im stupid
+- Item Relationship System (items that belong to another item, item must be in same zone though for that)
+- Item Replacement system
+    - Give the ability to replace an item with another one in some way ?
+        - Say I have a keyboard in storage and want to replace on from a computer like that should be possible
+
+
+
+
+## Part 3 :
+- make it actuall support backends RBAC lol
+
+
+## Part 4 :
+- Properly implementing issue view (currently a "design" concept was slapped there by an LLM)
+- Reporting and analytics (allow printing paper reports on rooms, items, zones, audits etc etc for non technical people)
+
+# Part 5 : 
+- Kiosk app work shall begin.
+
+# Later : 

+ 310 - 0
src/core/components/history.rs

@@ -0,0 +1,310 @@
+use eframe::egui;
+use serde_json::Value;
+use crate::api::ApiClient;
+use crate::core::table_renderer::{TableRenderer, ColumnConfig, TableEventHandler};
+use crate::core::tables::{decode_base64_json, format_asset_change_short};
+use crate::models::{Join, OrderBy};
+
+pub struct HistoryWindow {
+    pub open: bool,
+    asset_id: i64,
+    asset_name: String,
+    asset_tag: String,
+    asset_numeric_id: Option<i64>,
+    belongs_to_item: Option<i64>,
+    previously_was: Option<i64>,
+    table: TableRenderer,
+    data: Vec<Value>,
+    loading: bool,
+    details_item: Option<Value>,
+}
+
+enum HistoryAction {
+    ShowDetails(Value),
+}
+
+struct HistoryEventHandler {
+    action: Option<HistoryAction>,
+}
+
+impl TableEventHandler<Value> for HistoryEventHandler {
+    fn on_double_click(&mut self, item: &Value, _row_index: usize) {
+        self.action = Some(HistoryAction::ShowDetails(item.clone()));
+    }
+    fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
+        if ui.button("Show Details").clicked() {
+            self.action = Some(HistoryAction::ShowDetails(item.clone()));
+            ui.close_menu();
+        }
+    }
+    fn on_selection_changed(&mut self, _selected_indices: &[usize]) {}
+}
+
+impl HistoryWindow {
+    pub fn new(asset: &Value, api_client: &ApiClient) -> Self {
+        let asset_id = asset.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
+        let asset_name = asset.get("name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
+        let asset_tag = asset.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("").to_string();
+        let asset_numeric_id = asset.get("asset_numeric_id").and_then(|v| v.as_i64());
+        let belongs_to_item = asset.get("belongs_to_item").and_then(|v| v.as_i64());
+        let previously_was = asset.get("previously_was").and_then(|v| v.as_i64());
+
+        let mut window = Self {
+            open: true,
+            asset_id,
+            asset_name,
+            asset_tag,
+            asset_numeric_id,
+            belongs_to_item,
+            previously_was,
+            table: TableRenderer::new()
+                .with_columns(vec![
+                    ColumnConfig::new("Date", "changed_at").with_width(150.0),
+                    ColumnConfig::new("User", "user_full_name").with_width(150.0),
+                    ColumnConfig::new("Action", "action").with_width(80.0),
+                    ColumnConfig::new("Changes", "summary").with_width(500.0),
+                ])
+                .with_default_sort("changed_at", false), // Descending
+            data: Vec::new(),
+            loading: true,
+            details_item: None,
+        };
+        
+        window.refresh_data(api_client);
+        window
+    }
+
+    pub fn refresh_data(&mut self, api_client: &ApiClient) {
+        self.loading = true;
+        
+        let joins = vec![
+            Join {
+                table: "users".into(),
+                on: "asset_change_log.changed_by_id = users.id".into(),
+                join_type: "LEFT".into(),
+            },
+        ];
+        
+        let columns = vec![
+            "asset_change_log.id".into(),
+            "asset_change_log.action".into(),
+            "asset_change_log.changed_fields".into(),
+            "asset_change_log.old_values".into(),
+            "asset_change_log.new_values".into(),
+            "asset_change_log.changed_at".into(),
+            "users.name as user_full_name".into(),
+        ];
+
+        // Filter by record_id and table_name
+        let where_clause = Some(serde_json::json!({
+            "asset_change_log.record_id": self.asset_id,
+            "asset_change_log.table_name": "assets"
+        }));
+
+        let resp = api_client.select_with_joins(
+            "asset_change_log",
+            Some(columns),
+            where_clause,
+            None, // filter
+            Some(vec![OrderBy {
+                column: "asset_change_log.changed_at".into(),
+                direction: "DESC".into(),
+            }]), // order_by
+            Some(100), // limit
+            Some(joins), // joins
+        );
+
+        match resp {
+            Ok(response) => {
+                if response.success {
+                    let rows = response.data.unwrap_or_default();
+                    
+                    // Transform rows
+                    self.data = rows.into_iter().map(|row| {
+                        let action = row.get("action").and_then(|v| v.as_str()).unwrap_or("");
+                        let decoded_fields = decode_base64_json(row.get("changed_fields"));
+                        let old_values = decode_base64_json(row.get("old_values"));
+                        let new_values = decode_base64_json(row.get("new_values"));
+                        
+                        let mut summary = format_asset_change_short(action, decoded_fields.as_ref());
+                        
+                        // Enhance summary with details if available
+                        if action == "UPDATE" {
+                            if let (Some(old), Some(new)) = (&old_values, &new_values) {
+                                if let Some(fields) = decoded_fields.as_ref().and_then(|v| v.as_array()) {
+                                    let details: Vec<String> = fields.iter().filter_map(|f| {
+                                        let field_name = f.as_str()?;
+                                        let old_val = old.get(field_name).map(|v| v.to_string()).unwrap_or_else(|| "null".to_string());
+                                        let new_val = new.get(field_name).map(|v| v.to_string()).unwrap_or_else(|| "null".to_string());
+                                        // Clean up quotes for strings
+                                        let old_clean = old_val.trim_matches('"');
+                                        let new_clean = new_val.trim_matches('"');
+                                        Some(format!("{}: {} -> {}", field_name, old_clean, new_clean))
+                                    }).collect();
+                                    
+                                    if !details.is_empty() {
+                                        summary = details.join(", ");
+                                    }
+                                }
+                            }
+                        }
+                        
+                        let mut new_row = row.clone();
+                        if let Some(obj) = new_row.as_object_mut() {
+                            obj.insert("summary".to_string(), Value::String(summary));
+                            if let Some(v) = old_values { obj.insert("old_values_decoded".into(), v); }
+                            if let Some(v) = new_values { obj.insert("new_values_decoded".into(), v); }
+                        }
+                        new_row
+                    }).collect();
+                } else {
+                    log::error!("Failed to fetch history: {:?}", response.error);
+                }
+            }
+            Err(e) => {
+                log::error!("Error fetching history: {:?}", e);
+            }
+        }
+
+        self.loading = false;
+    }
+
+    pub fn show(&mut self, ctx: &egui::Context, _api_client: &ApiClient) {
+        let mut open = self.open;
+        egui::Window::new(format!("History: {}", self.asset_name))
+            .open(&mut open)
+            .resize(|r| r.default_size([900.0, 600.0]))
+            .show(ctx, |ui| {
+                // Basic Overview
+                ui.collapsing("Asset Overview", |ui| {
+                    egui::Grid::new("asset_overview_grid").num_columns(2).spacing([20.0, 8.0]).show(ui, |ui| {
+                        ui.label("Asset Tag:");
+                        ui.strong(&self.asset_tag);
+                        ui.end_row();
+
+                        ui.label("Name:");
+                        ui.label(&self.asset_name);
+                        ui.end_row();
+
+                        if let Some(nid) = self.asset_numeric_id {
+                            ui.label("Numeric ID:");
+                            ui.label(nid.to_string());
+                            ui.end_row();
+                        }
+
+                        if let Some(pid) = self.belongs_to_item {
+                            ui.label("Belongs To (Numeric ID):");
+                            ui.label(pid.to_string());
+                            ui.end_row();
+                        }
+
+                        if let Some(prev) = self.previously_was {
+                            ui.label("Previously Was (Numeric ID):");
+                            ui.label(prev.to_string());
+                            ui.end_row();
+                        }
+                    });
+                });
+                
+                ui.add_space(10.0);
+                ui.separator();
+                ui.add_space(10.0);
+                ui.heading("Change Log");
+
+                if self.loading {
+                    ui.spinner();
+                } else {
+                    let mut event_handler = HistoryEventHandler { action: None };
+                    let prepared_data = self.table.prepare_json_data(&self.data);
+                    self.table.render_json_table(ui, &prepared_data, Some(&mut event_handler));
+                    
+                    if let Some(action) = event_handler.action {
+                        match action {
+                            HistoryAction::ShowDetails(item) => self.details_item = Some(item),
+                        }
+                    }
+                }
+            });
+        self.open = open;
+
+        if let Some(item) = &self.details_item {
+            let mut open = true;
+            egui::Window::new("Change Details")
+                .open(&mut open)
+                .resize(|r| r.default_size([500.0, 400.0]))
+                .show(ctx, |ui| {
+                    self.render_details(ui, item);
+                });
+            if !open {
+                self.details_item = None;
+            }
+        }
+    }
+
+    fn render_details(&self, ui: &mut egui::Ui, item: &Value) {
+        ui.heading("Change Details");
+        ui.separator();
+        
+        egui::Grid::new("details_grid").num_columns(2).spacing([20.0, 8.0]).show(ui, |ui| {
+            ui.label(egui::RichText::new("Date:").strong());
+            ui.label(item.get("changed_at").and_then(|v| v.as_str()).unwrap_or("-"));
+            ui.end_row();
+            
+            ui.label(egui::RichText::new("User:").strong());
+            ui.label(item.get("user_full_name").and_then(|v| v.as_str()).unwrap_or("-"));
+            ui.end_row();
+            
+            ui.label(egui::RichText::new("Action:").strong());
+            ui.label(item.get("action").and_then(|v| v.as_str()).unwrap_or("-"));
+            ui.end_row();
+        });
+        
+        ui.add_space(16.0);
+        ui.separator();
+        ui.add_space(8.0);
+        ui.heading("Field Changes");
+        ui.add_space(8.0);
+        
+        let old_values = item.get("old_values_decoded");
+        let new_values = item.get("new_values_decoded");
+        let changed_fields = decode_base64_json(item.get("changed_fields"));
+
+        if let Some(fields) = changed_fields.as_ref().and_then(|v| v.as_array()) {
+            egui::ScrollArea::vertical().show(ui, |ui| {
+                egui::Grid::new("changes_grid").num_columns(3).striped(true).spacing([20.0, 8.0]).show(ui, |ui| {
+                    ui.label(egui::RichText::new("Field").strong());
+                    ui.label(egui::RichText::new("Old Value").strong());
+                    ui.label(egui::RichText::new("New Value").strong());
+                    ui.end_row();
+
+                    for field in fields {
+                        if let Some(field_name) = field.as_str() {
+                            ui.label(field_name);
+                            
+                            let old_val = old_values.and_then(|o| o.get(field_name)).map(|v| v.to_string()).unwrap_or_else(|| "-".to_string());
+                            let new_val = new_values.and_then(|n| n.get(field_name)).map(|v| v.to_string()).unwrap_or_else(|| "-".to_string());
+                            
+                            // Clean up quotes for strings if they are just simple strings
+                            let old_clean = if old_val.starts_with('"') && old_val.ends_with('"') {
+                                old_val[1..old_val.len()-1].to_string()
+                            } else {
+                                old_val
+                            };
+                            let new_clean = if new_val.starts_with('"') && new_val.ends_with('"') {
+                                new_val[1..new_val.len()-1].to_string()
+                            } else {
+                                new_val
+                            };
+
+                            ui.label(old_clean);
+                            ui.label(new_clean);
+                            ui.end_row();
+                        }
+                    }
+                });
+            });
+        } else {
+            ui.label("No specific field changes recorded.");
+        }
+    }
+}

+ 1 - 0
src/core/components/mod.rs

@@ -3,6 +3,7 @@ pub mod clone;
 pub mod filter_builder;
 pub mod form_builder;
 pub mod help;
+pub mod history;
 pub mod interactions;
 pub mod stats;
 

+ 42 - 0
src/core/data/asset_fields.rs

@@ -259,6 +259,27 @@ impl AssetFieldBuilder {
                 required: true,
                 read_only: false,
             },
+            EditorField {
+                name: "tag_generation_string".into(),
+                label: "Tag Generation String".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
+            EditorField {
+                name: "belongs_to_item".into(),
+                label: "Belongs To (Numeric ID)".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
+            EditorField {
+                name: "previously_was".into(),
+                label: "Previously Was (Numeric ID)".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
             EditorField {
                 name: "asset_type".into(),
                 label: "Type".into(),
@@ -531,6 +552,27 @@ impl AssetFieldBuilder {
                 required: false,
                 read_only: false,
             },
+            EditorField {
+                name: "tag_generation_string".into(),
+                label: "Tag Generation String".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
+            EditorField {
+                name: "belongs_to_item".into(),
+                label: "Belongs To (Numeric ID)".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
+            EditorField {
+                name: "previously_was".into(),
+                label: "Previously Was (Numeric ID)".into(),
+                field_type: FieldType::Text,
+                required: false,
+                read_only: false,
+            },
             // Core fields
             EditorField {
                 name: "asset_type".into(),

+ 1 - 0
src/core/table_renderer.rs

@@ -595,6 +595,7 @@ impl JsonCellRenderer {
                     "Faulty" => (value, egui::Color32::from_rgb(244, 67, 54)),
                     "Missing" => (value, egui::Color32::from_rgb(244, 67, 54)),
                     "Retired" => (value, egui::Color32::GRAY),
+                    "Scrapped" => (value, egui::Color32::from_rgb(128, 0, 0)),
                     "In Repair" => (value, egui::Color32::from_rgb(156, 39, 176)),
                     "In Transit" => (value, egui::Color32::from_rgb(33, 150, 243)),
                     "Expired" => (value, egui::Color32::from_rgb(183, 28, 28)),

+ 7 - 2
src/core/tables.rs

@@ -5,7 +5,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
 use base64::Engine as _;
 use serde_json::{json, Value};
 
-fn decode_base64_json(value: Option<&serde_json::Value>) -> Option<serde_json::Value> {
+pub fn decode_base64_json(value: Option<&serde_json::Value>) -> Option<serde_json::Value> {
     let s = value.and_then(|v| v.as_str())?;
     if s.is_empty() || s == "NULL" {
         return None;
@@ -24,7 +24,7 @@ fn compact_json(value: &serde_json::Value) -> String {
     }
 }
 
-fn format_asset_change_short(action: &str, changed_fields: Option<&serde_json::Value>) -> String {
+pub fn format_asset_change_short(action: &str, changed_fields: Option<&serde_json::Value>) -> String {
     match action {
         "INSERT" => "Created".to_string(),
         "DELETE" => "Deleted".to_string(),
@@ -377,7 +377,10 @@ pub fn get_all_assets(
     let columns = Some(vec![
         "assets.id".to_string(),
         "assets.asset_tag".to_string(),
+        "assets.tag_generation_string".to_string(),
         "assets.asset_numeric_id".to_string(),
+        "assets.belongs_to_item".to_string(),
+        "assets.previously_was".to_string(),
         "assets.asset_type".to_string(),
         "assets.name".to_string(),
         "assets.category_id".to_string(),
@@ -554,6 +557,8 @@ pub fn get_assets_in_zone(
         "assets.id".to_string(),
         "assets.asset_numeric_id".to_string(),
         "assets.asset_tag".to_string(),
+        "assets.belongs_to_item".to_string(),
+        "assets.previously_was".to_string(),
         "assets.name".to_string(),
         "assets.status".to_string(),
         "assets.lending_status".to_string(),

+ 5 - 0
src/core/workflows/add_from_template.rs

@@ -371,6 +371,11 @@ impl AddFromTemplateWorkflow {
             log::warn!("Template has no zone_code field (this is normal if zone_id is null)");
         }
 
+        // Map asset_tag_generation_string from template to tag_generation_string on asset
+        if let Some(gen_string) = template.get("asset_tag_generation_string").and_then(|v| v.as_str()) {
+            asset_data["tag_generation_string"] = Value::String(gen_string.to_string());
+        }
+
         // Apply initial auto-generation so the user sees defaults inside the editor
         // 1) Purchase date now
         if template

+ 425 - 9
src/core/workflows/audit.rs

@@ -11,9 +11,11 @@ use serde_json::{json, Map, Value};
 
 use crate::api::ApiClient;
 use crate::core::components::interactions::ConfirmDialog;
+use crate::core::print::PrintDialog;
 use crate::core::tables::{
     find_asset_by_tag_or_numeric, find_zone_by_code, get_assets_in_zone, get_audit_task_definition,
 };
+use crate::models::QueryRequest;
 
 const STATUS_OPTIONS: &[&str] = &[
     "Good",
@@ -95,6 +97,8 @@ impl AuditScanPolicy {
 struct AuditAssetState {
     asset_id: i64,
     asset_numeric_id: Option<i64>,
+    belongs_to_item: Option<i64>,
+    previously_was: Option<i64>,
     asset_tag: String,
     name: String,
     _status_before: Option<String>,
@@ -138,6 +142,8 @@ impl AuditAssetState {
         Ok(Self {
             asset_id,
             asset_numeric_id: value.get("asset_numeric_id").and_then(|v| v.as_i64()),
+            belongs_to_item: value.get("belongs_to_item").and_then(|v| v.as_i64()),
+            previously_was: value.get("previously_was").and_then(|v| v.as_i64()),
             asset_tag,
             name,
             _status_before: status_before,
@@ -270,6 +276,27 @@ enum PendingFinalizeIntent {
     FromDialog { force_missing: bool },
 }
 
+#[derive(Debug, Clone)]
+struct UnexpectedAssetDialogState {
+    asset_id: i64,
+    asset_tag: String,
+    asset_name: String,
+    current_zone_name: String,
+    current_zone_id: Option<i64>,
+    is_missing: bool,
+    scanned_value: Value, // The full asset value to add later
+}
+
+#[derive(Debug, Clone)]
+struct AssetTagEditState {
+    asset_id: i64,
+    current_tag: String,
+    new_tag: String,
+    original_scanned_value: Value,
+    target_zone_id: i64,
+    print_after_update: bool,
+}
+
 pub struct AuditWorkflow {
     is_open: bool,
     mode: AuditMode,
@@ -292,6 +319,11 @@ pub struct AuditWorkflow {
     completion_snapshot: Option<AuditCompletion>,
     user_id: Option<i64>,
     pending_finalize: Option<PendingFinalizeIntent>,
+    
+    // Unexpected asset handling
+    unexpected_dialog: Option<UnexpectedAssetDialogState>,
+    tag_edit_state: Option<AssetTagEditState>,
+    print_dialog: Option<PrintDialog>,
 }
 
 impl AuditWorkflow {
@@ -336,6 +368,9 @@ impl AuditWorkflow {
             completion_snapshot: None,
             user_id: None,
             pending_finalize: None,
+            unexpected_dialog: None,
+            tag_edit_state: None,
+            print_dialog: None,
         }
     }
 
@@ -450,6 +485,28 @@ impl AuditWorkflow {
                 self.render_scanning(ui, ctx, api_client);
             });
 
+        // Handle close attempt
+        if !keep_open && self.is_open {
+            // Re-open immediately because we want to show confirmation first
+            // But we can't easily re-open in the same frame if we don't own the state fully
+            // Actually, we do own self.is_open.
+            // If keep_open is false, it means user clicked X.
+            // We should trigger the cancel dialog.
+            self.cancel_dialog.open("Current Audit", "cancel");
+        }
+
+        // Render dialogs
+        self.render_unexpected_dialog(ctx, api_client);
+        self.render_tag_edit_dialog(ctx, api_client);
+
+        if let Some(dialog) = &mut self.print_dialog {
+            let mut open = true;
+            dialog.show(ctx, &mut open, Some(api_client));
+            if !open {
+                self.print_dialog = None;
+            }
+        }
+
         if !keep_open {
             self.cancel_without_saving();
         }
@@ -494,6 +551,12 @@ impl AuditWorkflow {
         if !self.is_open {
             keep_open = false;
         }
+        
+        // If cancel dialog is open, force keep_open to true so the window doesn't close
+        if self.cancel_dialog.show {
+            keep_open = true;
+        }
+        
         self.is_open = keep_open;
         keep_open
     }
@@ -522,6 +585,9 @@ impl AuditWorkflow {
         self.current_task_runner = None;
         self.user_id = None;
         self.pending_finalize = None;
+        self.unexpected_dialog = None;
+        self.tag_edit_state = None;
+        self.print_dialog = None;
         // Preserve cached_tasks so audit tasks are reused between runs
     }
 
@@ -732,6 +798,328 @@ impl AuditWorkflow {
         }
     }
 
+    fn render_unexpected_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) {
+        if let Some(state) = &self.unexpected_dialog {
+            let mut open = true;
+            
+            #[derive(PartialEq)]
+            enum Action {
+                None,
+                Move(bool),
+                Keep,
+                Cancel
+            }
+            let mut action = Action::None;
+            
+            let current_zone_name = self.zone_info.as_ref().map(|z| z.zone_name.clone()).unwrap_or_else(|| "Current".to_string());
+            
+            egui::Window::new("Unexpected Asset Found")
+                .collapsible(false)
+                .resizable(true)
+                .movable(true)
+                .open(&mut open)
+                .show(ctx, |ui| {
+                    ui.heading(format!("Asset: {} ({})", state.asset_tag, state.asset_name));
+                    ui.add_space(8.0);
+                    
+                    if state.is_missing {
+                        ui.colored_label(egui::Color32::RED, "This asset is marked MISSING in another zone!");
+                    } else {
+                        ui.label(format!("This asset belongs to: {}", state.current_zone_name));
+                    }
+                    
+                    ui.add_space(16.0);
+                    ui.label("What would you like to do?");
+                    ui.add_space(8.0);
+                    
+                    // Option 1: Move to this zone
+                    if ui.button(format!("Move to THIS zone ({}) & Print Label", current_zone_name)).clicked() {
+                        action = Action::Move(true);
+                    }
+                    
+                    if ui.button(format!("Move to THIS zone ({}) (No Label)", current_zone_name)).clicked() {
+                        action = Action::Move(false);
+                    }
+                    
+                    ui.add_space(8.0);
+                    
+                    // Option 2: Return to correct zone / Keep in old zone
+                    let return_text = if state.is_missing {
+                        "Mark found but return to OLD zone"
+                    } else {
+                        "Return to OLD zone (Wrong Place)"
+                    };
+                    
+                    if ui.button(return_text).clicked() {
+                        action = Action::Keep;
+                    }
+                    
+                    ui.add_space(8.0);
+                    if ui.button("Cancel / Ignore").clicked() {
+                        action = Action::Cancel;
+                    }
+                });
+            
+            match action {
+                Action::Move(print) => {
+                    if print {
+                        // Open tag edit dialog
+                        if let Some(zone) = &self.zone_info {
+                            self.tag_edit_state = Some(AssetTagEditState {
+                                asset_id: state.asset_id,
+                                current_tag: state.asset_tag.clone(),
+                                new_tag: state.asset_tag.clone(),
+                                original_scanned_value: state.scanned_value.clone(),
+                                target_zone_id: zone.id,
+                                print_after_update: true,
+                            });
+                            self.unexpected_dialog = None;
+                        }
+                    } else {
+                        self.handle_unexpected_move(api_client, false);
+                        self.unexpected_dialog = None;
+                    }
+                }
+                Action::Keep => {
+                    self.handle_unexpected_keep(api_client);
+                    self.unexpected_dialog = None;
+                }
+                Action::Cancel => {
+                    self.unexpected_dialog = None;
+                }
+                Action::None => {
+                    if !open {
+                        self.unexpected_dialog = None;
+                    }
+                }
+            }
+        }
+    }
+
+    fn render_tag_edit_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) {
+        if let Some(state) = &mut self.tag_edit_state {
+            let mut open = true;
+            let mut confirmed = false;
+            let mut cancelled = false;
+
+            egui::Window::new("Update Asset Tag")
+                .collapsible(false)
+                .resizable(true)
+                .movable(true)
+                .open(&mut open)
+                .show(ctx, |ui| {
+                    ui.heading("Update Asset Tag");
+                    ui.label("You are moving this asset. Do you want to update the asset tag?");
+                    ui.add_space(8.0);
+
+                    ui.horizontal(|ui| {
+                        ui.label("Current Tag:");
+                        ui.strong(&state.current_tag);
+                    });
+
+                    ui.horizontal(|ui| {
+                        ui.label("New Tag:");
+                        ui.text_edit_singleline(&mut state.new_tag);
+                    });
+
+                    ui.add_space(16.0);
+                    ui.horizontal(|ui| {
+                        if ui.button("Save & Print").clicked() {
+                            confirmed = true;
+                        }
+                        if ui.button("Skip Update (Just Print)").clicked() {
+                            // Revert to original tag but proceed
+                            state.new_tag = state.current_tag.clone();
+                            confirmed = true;
+                        }
+                        if ui.button("Cancel").clicked() {
+                            cancelled = true;
+                        }
+                    });
+                });
+
+            if confirmed {
+                self.handle_tag_update_and_move(api_client);
+                self.tag_edit_state = None;
+            } else if cancelled || !open {
+                self.tag_edit_state = None;
+            }
+        }
+    }
+
+    fn handle_tag_update_and_move(&mut self, api_client: &ApiClient) {
+        let state = if let Some(s) = &self.tag_edit_state { s.clone() } else { return; };
+        
+        // 1. Update asset in DB
+        let mut update_data = serde_json::Map::new();
+        update_data.insert("zone_id".to_string(), json!(state.target_zone_id));
+        update_data.insert("status".to_string(), json!("Good"));
+        
+        if state.new_tag != state.current_tag {
+            update_data.insert("asset_tag".to_string(), json!(state.new_tag));
+        }
+
+        let update_payload = json!({
+            "action": "update",
+            "table": "assets",
+            "data": update_data,
+            "where": { "id": state.asset_id }
+        });
+
+        if let Ok(request) = serde_json::from_value::<QueryRequest>(update_payload) {
+            if let Ok(resp) = api_client.query(&request) {
+                if resp.success {
+                    // 2. Add to expected_assets
+                    let mut new_asset_val = state.original_scanned_value.clone();
+                    if let Some(obj) = new_asset_val.as_object_mut() {
+                        obj.insert("zone_id".to_string(), json!(state.target_zone_id));
+                        obj.insert("status".to_string(), json!("Good"));
+                        if state.new_tag != state.current_tag {
+                            obj.insert("asset_tag".to_string(), json!(state.new_tag));
+                        }
+                    }
+
+                    if let Ok(mut asset_state) = AuditAssetState::from_value(
+                        new_asset_val.clone(), 
+                        Some(state.target_zone_id), 
+                        true 
+                    ) {
+                        asset_state.scanned = true;
+                        asset_state.completed_at = Some(Utc::now());
+                        asset_state.status_found = "Good".to_string();
+                        
+                        self.expected_assets.push(asset_state);
+                        self.selected_asset = Some(self.expected_assets.len() - 1);
+                    }
+
+                    // 3. Open Print Dialog if requested
+                    if state.print_after_update {
+                        // Prepare asset data for print dialog
+                        let mut asset_data = HashMap::new();
+                        if let Some(obj) = new_asset_val.as_object() {
+                            for (k, v) in obj {
+                                if let Some(s) = v.as_str() {
+                                    asset_data.insert(k.clone(), s.to_string());
+                                } else {
+                                    asset_data.insert(k.clone(), v.to_string());
+                                }
+                            }
+                        }
+                        
+                        // Add zone name if available
+                        if let Some(zone) = &self.zone_info {
+                            asset_data.insert("zone_name".to_string(), zone.zone_name.clone());
+                            if let Some(code) = &zone.zone_code {
+                                asset_data.insert("zone_code".to_string(), code.clone());
+                            }
+                        }
+
+                        let mut dialog = PrintDialog::new(asset_data);
+                        if let Err(e) = dialog.load_data(api_client) {
+                            log::error!("Failed to load print dialog data: {}", e);
+                        }
+                        self.print_dialog = Some(dialog);
+                    }
+                } else {
+                    self.last_error = Some(format!("Failed to update asset: {:?}", resp.error));
+                }
+            }
+        }
+    }
+
+    fn handle_unexpected_move(&mut self, api_client: &ApiClient, print_label: bool) {
+        // Clone state to avoid borrow checker issues
+        let state = if let Some(s) = &self.unexpected_dialog { s.clone() } else { return; };
+        
+        if let Some(current_zone) = &self.zone_info {
+            // 1. Update asset zone and status
+            let mut update_data = serde_json::Map::new();
+            update_data.insert("zone_id".to_string(), json!(current_zone.id));
+            update_data.insert("status".to_string(), json!("Good")); // Reset status to Good
+            
+            let update_payload = json!({
+                "action": "update",
+                "table": "assets",
+                "data": update_data,
+                "where": { "id": state.asset_id }
+            });
+            
+            if let Ok(request) = serde_json::from_value::<QueryRequest>(update_payload) {
+                if let Ok(resp) = api_client.query(&request) {
+                    if resp.success {
+                        // 2. Add to expected_assets as a valid asset for this zone
+                        let mut new_asset_val = state.scanned_value.clone();
+                        if let Some(obj) = new_asset_val.as_object_mut() {
+                            obj.insert("zone_id".to_string(), json!(current_zone.id));
+                            obj.insert("status".to_string(), json!("Good"));
+                        }
+                        
+                        if let Ok(mut asset_state) = AuditAssetState::from_value(
+                            new_asset_val, 
+                            Some(current_zone.id), 
+                            true // Now it IS expected because we moved it here
+                        ) {
+                            asset_state.scanned = true;
+                            asset_state.completed_at = Some(Utc::now());
+                            asset_state.status_found = "Good".to_string();
+                            
+                            self.expected_assets.push(asset_state);
+                            self.selected_asset = Some(self.expected_assets.len() - 1);
+                        }
+                        
+                        // 3. Print label if requested
+                        if print_label {
+                            log::info!("TODO: Trigger label print for asset {}", state.asset_tag);
+                        }
+                    } else {
+                        self.last_error = Some(format!("Failed to move asset: {:?}", resp.error));
+                    }
+                }
+            }
+        }
+    }
+
+    fn handle_unexpected_keep(&mut self, api_client: &ApiClient) {
+        let state = if let Some(s) = &self.unexpected_dialog { s.clone() } else { return; };
+        
+         // If it was missing, we should probably update it to "Good" but keep the zone.
+         if state.is_missing {
+            let mut update_data = serde_json::Map::new();
+            update_data.insert("status".to_string(), json!("Good"));
+            
+            let update_payload = json!({
+                "action": "update",
+                "table": "assets",
+                "data": update_data,
+                "where": { "id": state.asset_id }
+            });
+            
+            if let Ok(request) = serde_json::from_value::<QueryRequest>(update_payload) {
+                let _ = api_client.query(&request);
+            }
+         }
+         
+         // Add to list as unexpected / wrong zone
+         if let Ok(mut asset_state) = AuditAssetState::from_value(
+            state.scanned_value.clone(), 
+            self.zone_info.as_ref().map(|z| z.id), 
+            false // Not expected
+        ) {
+            asset_state.scanned = true;
+            asset_state.completed_at = Some(Utc::now());
+            asset_state.status_found = "Good".to_string(); // We found it, so it's good physically
+            
+            // Set exception details
+            asset_state.exception_type = Some(EXCEPTION_WRONG_ZONE.to_string());
+            asset_state.exception_details = Some(format!(
+                "Belongs to {}, found here.", state.current_zone_name
+            ));
+            
+            self.expected_assets.push(asset_state);
+            self.selected_asset = Some(self.expected_assets.len() - 1);
+        }
+    }
+
     fn render_scanning(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, api_client: &ApiClient) {
         let required_total = self.required_total();
         let completed_total = self.completed_total();
@@ -868,6 +1256,12 @@ impl AuditWorkflow {
                 if let Some(asset) = self.expected_assets.get_mut(idx) {
                     right.label(format!("Asset Tag: {}", asset.asset_tag));
                     right.label(format!("Name: {}", asset.name));
+                    if let Some(parent_id) = asset.belongs_to_item {
+                        right.label(format!("Belongs To: {}", parent_id));
+                    }
+                    if let Some(prev_id) = asset.previously_was {
+                        right.label(format!("Previously Was: {}", prev_id));
+                    }
                     if !asset.expected {
                         right.colored_label(
                             egui::Color32::from_rgb(255, 152, 0),
@@ -1026,21 +1420,43 @@ impl AuditWorkflow {
         // Asset not in current list, try to fetch from the API
         if let Some(value) = find_asset_by_tag_or_numeric(api_client, input)? {
             let zone_id = value.get("zone_id").and_then(|v| v.as_i64());
+            
+            // Check if it's a wrong zone situation
+            if let Some(zone) = &self.zone_info {
+                if zone_id != Some(zone.id) {
+                    // Trigger unexpected asset dialog
+                    let asset_id = value.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
+                    let asset_tag = value.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("").to_string();
+                    let asset_name = value.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
+                    let status = value.get("status").and_then(|v| v.as_str()).unwrap_or("Good");
+                    
+                    // Fetch current zone name if possible (optional, for display)
+                    // We don't have it readily available unless we fetch it, but we can just show ID or "Another Zone"
+                    let current_zone_name = "Another Zone".to_string(); 
+                    let current_zone_id = value.get("zone_id").and_then(|v| v.as_i64());
+
+                    self.unexpected_dialog = Some(UnexpectedAssetDialogState {
+                        asset_id,
+                        asset_tag,
+                        asset_name,
+                        current_zone_name,
+                        current_zone_id,
+                        is_missing: status == "Missing",
+                        scanned_value: value.clone(),
+                    });
+                    self.scan_input.clear();
+                    return Ok(());
+                }
+            }
+
             let mut state = AuditAssetState::from_value(
                 value,
                 self.zone_info.as_ref().map(|z| z.id),
                 self.mode == AuditMode::FullZone && self.zone_info.is_some(),
             )?;
 
-            if let Some(zone) = &self.zone_info {
-                if zone_id != Some(zone.id) {
-                    state.expected = false;
-                    state.exception_type = Some(EXCEPTION_WRONG_ZONE.to_string());
-                    state.exception_details = Some(format!(
-                        "Asset assigned to zone {:?}, found in {}",
-                        zone_id, zone.zone_name
-                    ));
-                } else if self.mode == AuditMode::FullZone {
+            if self.zone_info.is_some() {
+                 if self.mode == AuditMode::FullZone {
                     state.expected = false;
                     state.exception_type = Some(EXCEPTION_UNEXPECTED_ASSET.to_string());
                     state.exception_details = Some("Asset not listed on zone roster".to_string());

+ 2 - 0
src/core/workflows/mod.rs

@@ -2,8 +2,10 @@
 pub mod add_from_template;
 pub mod audit;
 pub mod borrow_flow;
+pub mod replacement_flow;
 pub mod return_flow;
 
 pub use add_from_template::AddFromTemplateWorkflow;
 pub use audit::AuditWorkflow;
+pub use replacement_flow::ReplacementWorkflow;
 // borrow_flow and return_flow accessed via qualified paths in views

+ 625 - 0
src/core/workflows/replacement_flow.rs

@@ -0,0 +1,625 @@
+use crate::api::ApiClient;
+use crate::models::{OrderBy, QueryRequest, ApiResponse};
+use eframe::egui;
+use serde_json::{json, Value};
+
+#[derive(PartialEq)]
+enum ReplacementStep {
+    SelectReplacement,
+    ConfigureOptions,
+    Processing,
+}
+
+pub struct ReplacementWorkflow {
+    pub is_open: bool,
+    step: ReplacementStep,
+    
+    // Source asset (the one being replaced)
+    source_asset: Option<Value>,
+    
+    // Selection state
+    search_query: String,
+    filter_same_category: bool,
+    selected_zone_id: Option<i64>,
+    available_zones: Vec<(i64, String)>,
+    search_results: Vec<Value>,
+    selected_replacement_index: Option<usize>,
+    
+    // Configuration state
+    replacement_asset: Option<Value>,
+    
+    // Options
+    new_status_for_old_item: String,
+    swap_tags: bool, // If true, new item gets old tag, old item gets new tag
+    print_label: bool,
+    copy_location: bool,
+    copy_belongs_to: bool,
+    copy_assignments: bool,
+    
+    // Status options for dropdown
+    status_options: Vec<String>,
+    
+    error: Option<String>,
+    success_message: Option<String>,
+}
+
+impl ReplacementWorkflow {
+    pub fn new() -> Self {
+        Self {
+            is_open: false,
+            step: ReplacementStep::SelectReplacement,
+            source_asset: None,
+            search_query: String::new(),
+            filter_same_category: true,
+            selected_zone_id: None,
+            available_zones: Vec::new(),
+            search_results: Vec::new(),
+            selected_replacement_index: None,
+            replacement_asset: None,
+            new_status_for_old_item: "Faulty".to_string(),
+            swap_tags: true,
+            print_label: true,
+            copy_location: true,
+            copy_belongs_to: true,
+            copy_assignments: true,
+            status_options: vec![
+                "Faulty".to_string(),
+                "Retired".to_string(),
+                "Missing".to_string(),
+                "In Repair".to_string(),
+                "Scrapped".to_string(),
+            ],
+            error: None,
+            success_message: None,
+        }
+    }
+
+    pub fn start(&mut self, source_asset: Value, api_client: &ApiClient) {
+        self.source_asset = Some(source_asset);
+        self.is_open = true;
+        self.step = ReplacementStep::SelectReplacement;
+        self.search_query = String::new();
+        self.filter_same_category = true;
+        self.selected_replacement_index = None;
+        self.replacement_asset = None;
+        self.error = None;
+        self.success_message = None;
+        
+        // Load zones for filter
+        self.load_zones(api_client);
+        
+        // Initial search
+        self.perform_search(api_client);
+    }
+
+    fn load_zones(&mut self, api_client: &ApiClient) {
+        if let Ok(resp) = api_client.select(
+            "zones", 
+            Some(vec!["id".into(), "zone_code".into(), "zone_name".into()]), 
+            None, 
+            Some(vec![OrderBy { column: "zone_name".into(), direction: "ASC".into() }]), 
+            None
+        ) {
+            if let Some(data) = resp.data {
+                self.available_zones = data.iter().filter_map(|r| {
+                    let id = r.get("id")?.as_i64()?;
+                    let name = r.get("zone_name")?.as_str()?;
+                    let code = r.get("zone_code")?.as_str()?;
+                    Some((id, format!("{} ({})", name, code)))
+                }).collect();
+            }
+        }
+    }
+
+    fn perform_search(&mut self, api_client: &ApiClient) {
+        let source = if let Some(s) = &self.source_asset { s } else { return };
+        
+        let mut filters = Vec::new();
+        
+        // Filter out the source asset itself
+        if let Some(id) = source.get("id").and_then(|v| v.as_i64()) {
+            filters.push(json!({ "column": "id", "op": "neq", "value": id }));
+        }
+        
+        // Category filter
+        if self.filter_same_category {
+            if let Some(cat_id) = source.get("category_id").and_then(|v| v.as_i64()) {
+                filters.push(json!({ "column": "category_id", "op": "eq", "value": cat_id }));
+            }
+        }
+        
+        // Zone filter
+        if let Some(zone_id) = self.selected_zone_id {
+            filters.push(json!({ "column": "zone_id", "op": "eq", "value": zone_id }));
+        }
+        
+        // Text search
+        if !self.search_query.is_empty() {
+            filters.push(json!({ 
+                "column": "asset_tag", 
+                "op": "like", 
+                "value": format!("%{}%", self.search_query) 
+            }));
+            // Note: Simple OR logic isn't easily supported by this basic filter builder 
+            // without more complex backend support, so we search by tag for now.
+        }
+        
+        // Only show "Good" or "Available" items? Maybe user wants to replace with something in stock.
+        // For now, let's show everything but maybe prioritize Good/Available.
+        
+        let _filter_json = if filters.is_empty() {
+            None
+        } else {
+            Some(json!(filters)) // This assumes the backend handles array of filters as AND
+        };
+        
+        // We need a custom query to handle the array of filters if the backend expects a single object or specific structure
+        // Assuming the standard select supports a list of filters or we construct a complex one.
+        // Let's try to use the `select` with a constructed filter object if possible, or just raw query if needed.
+        // For now, let's assume the backend `select` can take a complex filter or we iterate.
+        
+        // Actually, let's use a raw query or a smarter select if available.
+        // The `get_all_assets` in tables.rs uses `select_with_joins`.
+        
+        // Let's construct a `where` clause for exact matches and `filter` for others.
+        let mut where_clause = serde_json::Map::new();
+        if self.filter_same_category {
+             if let Some(cat_id) = source.get("category_id").and_then(|v| v.as_i64()) {
+                 where_clause.insert("category_id".to_string(), json!(cat_id));
+             }
+        }
+        if let Some(zone_id) = self.selected_zone_id {
+            where_clause.insert("zone_id".to_string(), json!(zone_id));
+        }
+        
+        let final_where = if where_clause.is_empty() { None } else { Some(Value::Object(where_clause)) };
+        
+        // For text search, we might need to filter client side if backend doesn't support mixed AND/OR easily
+        // or use the `filter` param for the LIKE.
+        let final_filter = if !self.search_query.is_empty() {
+            Some(json!({
+                "column": "asset_tag",
+                "op": "like",
+                "value": format!("%{}%", self.search_query)
+            }))
+        } else {
+            None
+        };
+
+        if let Ok(mut results) = crate::core::tables::get_all_assets(api_client, Some(50), final_where, final_filter) {
+            // Client-side filtering to exclude source asset
+            if let Some(source_id) = source.get("id").and_then(|v| v.as_i64()) {
+                results.retain(|r| r.get("id").and_then(|v| v.as_i64()) != Some(source_id));
+            }
+            self.search_results = results;
+        }
+    }
+
+    pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> Option<bool> {
+        if !self.is_open {
+            return None;
+        }
+
+        let mut open = true;
+        let result = None;
+
+        egui::Window::new("Replace Item Workflow")
+            .open(&mut open)
+            .resizable(true)
+            .default_width(600.0)
+            .default_height(500.0)
+            .show(ctx, |ui| {
+                match self.step {
+                    ReplacementStep::SelectReplacement => self.render_selection(ui, api_client),
+                    ReplacementStep::ConfigureOptions => self.render_configuration(ui, api_client),
+                    ReplacementStep::Processing => {
+                        ui.centered_and_justified(|ui| {
+                            ui.spinner();
+                            ui.label("Processing replacement...");
+                        });
+                    }
+                }
+            });
+
+        if !open {
+            self.is_open = false;
+        }
+        
+        result
+    }
+
+    fn render_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) {
+        ui.heading("Step 1: Select Replacement Item");
+        ui.add_space(10.0);
+        
+        let source_tag = self.source_asset.as_ref().and_then(|a| a.get("asset_tag").and_then(|v| v.as_str())).unwrap_or("Unknown");
+        ui.label(format!("Replacing item: {}", source_tag));
+        ui.separator();
+        
+        // Filters
+        ui.horizontal(|ui| {
+            if ui.checkbox(&mut self.filter_same_category, "Same Category").changed() {
+                self.perform_search(api_client);
+            }
+            
+            ui.label("Zone:");
+            let selected_zone_idx = self.available_zones.iter().position(|(id, _)| Some(*id) == self.selected_zone_id);
+            let _ = egui::ComboBox::from_id_salt("zone_filter")
+                .selected_text(
+                    selected_zone_idx
+                        .map(|i| self.available_zones[i].1.clone())
+                        .unwrap_or_else(|| "All Zones".to_string())
+                )
+                .show_ui(ui, |ui| {
+                    let mut new_selection = None;
+                    if ui.selectable_label(self.selected_zone_id.is_none(), "All Zones").clicked() {
+                        new_selection = Some(None);
+                    }
+                    // Clone to avoid borrowing self while mutating self later
+                    let zones = self.available_zones.clone();
+                    for (id, name) in &zones {
+                        if ui.selectable_label(Some(*id) == self.selected_zone_id, name).clicked() {
+                            new_selection = Some(Some(*id));
+                        }
+                    }
+                    
+                    if let Some(sel) = new_selection {
+                        self.selected_zone_id = sel;
+                        self.perform_search(api_client);
+                    }
+                });
+        });
+        
+        ui.horizontal(|ui| {
+            ui.label("Search:");
+            if ui.text_edit_singleline(&mut self.search_query).changed() {
+                self.perform_search(api_client);
+            }
+        });
+        
+        ui.separator();
+        
+        // Results List
+        egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
+            for (idx, asset) in self.search_results.iter().enumerate() {
+                let tag = asset.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("No Tag");
+                let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or("No Name");
+                let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or("-");
+                let zone = asset.get("zone_code").and_then(|v| v.as_str()).unwrap_or("-");
+                
+                let label = format!("{} - {} (Status: {}, Zone: {})", tag, name, status, zone);
+                
+                if ui.selectable_label(self.selected_replacement_index == Some(idx), label).clicked() {
+                    self.selected_replacement_index = Some(idx);
+                }
+            }
+            
+            if self.search_results.is_empty() {
+                ui.label("No assets found matching criteria.");
+            }
+        });
+        
+        ui.add_space(10.0);
+        ui.separator();
+        
+        ui.horizontal(|ui| {
+            if ui.button("Cancel").clicked() {
+                self.is_open = false;
+            }
+            
+            let can_proceed = self.selected_replacement_index.is_some();
+            if ui.add_enabled(can_proceed, egui::Button::new("Next")).clicked() {
+                if let Some(idx) = self.selected_replacement_index {
+                    self.replacement_asset = Some(self.search_results[idx].clone());
+                    self.step = ReplacementStep::ConfigureOptions;
+                }
+            }
+        });
+    }
+
+    fn render_configuration(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) {
+        ui.heading("Step 2: Configure Replacement");
+        ui.add_space(10.0);
+        
+        let source = self.source_asset.as_ref().unwrap();
+        let replacement = self.replacement_asset.as_ref().unwrap();
+        
+        let source_tag = source.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("?");
+        let replacement_tag = replacement.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("?");
+        
+        ui.label(format!("Replacing: {}", source_tag));
+        ui.label(format!("With: {}", replacement_tag));
+        
+        ui.add_space(10.0);
+        ui.separator();
+        
+        // Options
+        egui::Grid::new("replacement_options").num_columns(2).spacing([10.0, 10.0]).show(ui, |ui| {
+            ui.label("Status for OLD item:");
+            egui::ComboBox::from_id_salt("old_status")
+                .selected_text(&self.new_status_for_old_item)
+                .show_ui(ui, |ui| {
+                    for status in &self.status_options {
+                        ui.selectable_value(&mut self.new_status_for_old_item, status.clone(), status);
+                    }
+                });
+            ui.end_row();
+            
+            ui.label("Swap Asset Tags?");
+            ui.checkbox(&mut self.swap_tags, "Yes (Old tag -> New item, New tag -> Old item)");
+            ui.end_row();
+            
+            if self.swap_tags {
+                ui.label("Resulting Tags:");
+                ui.vertical(|ui| {
+                    ui.label(format!("New Item will be: {}", source_tag));
+                    ui.label(format!("Old Item will be: {}", replacement_tag));
+                });
+                ui.end_row();
+            }
+            
+            ui.label("Print Label for New Item?");
+            ui.checkbox(&mut self.print_label, "Yes");
+            ui.end_row();
+
+            ui.label("Copy Location (Zone)?");
+            ui.checkbox(&mut self.copy_location, "Yes (Move new item to old item's zone)");
+            ui.end_row();
+
+            ui.label("Copy 'Belongs To'?");
+            ui.checkbox(&mut self.copy_belongs_to, "Yes (New item belongs to same parent)");
+            ui.end_row();
+
+            ui.label("Copy Assignments?");
+            ui.checkbox(&mut self.copy_assignments, "Yes (Transfer current borrower)");
+            ui.end_row();
+        });
+        
+        ui.add_space(20.0);
+        if let Some(err) = &self.error {
+            ui.colored_label(egui::Color32::RED, format!("Error: {}", err));
+        }
+        
+        ui.horizontal(|ui| {
+            if ui.button("Back").clicked() {
+                self.step = ReplacementStep::SelectReplacement;
+            }
+            
+            if ui.button("Confirm Replacement").clicked() {
+                self.execute_replacement(api_client);
+            }
+        });
+    }
+
+    fn execute_replacement(&mut self, api_client: &ApiClient) {
+        self.step = ReplacementStep::Processing;
+        
+        let source = self.source_asset.as_ref().unwrap();
+        let replacement = self.replacement_asset.as_ref().unwrap();
+        
+        let source_id = source.get("id").and_then(|v| v.as_i64()).unwrap();
+        let replacement_id = replacement.get("id").and_then(|v| v.as_i64()).unwrap();
+        
+        let source_tag = source.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("").to_string();
+        let replacement_tag = replacement.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("").to_string();
+        
+        let source_numeric_id = source.get("asset_numeric_id").and_then(|v| v.as_i64());
+        
+        // Helper to check response
+        let check_resp = |resp: Result<ApiResponse<Value>, anyhow::Error>| -> Result<(), String> {
+            match resp {
+                Ok(r) => if r.success { Ok(()) } else { Err(r.error.unwrap_or_else(|| "Unknown error".to_string())) },
+                Err(e) => Err(e.to_string()),
+            }
+        };
+
+        // 1. If swapping tags, we need a 3-step process to avoid unique constraint violations
+        //    Step A: Rename Source to Temp
+        //    Step B: Rename Replacement to Source
+        //    Step C: Rename Source (Temp) to Replacement
+        
+        if self.swap_tags {
+            let temp_tag = format!("{}_TEMP_{}", source_tag, chrono::Utc::now().timestamp());
+            
+            // Step A: Source -> Temp
+            let mut temp_update = serde_json::Map::new();
+            temp_update.insert("asset_tag".to_string(), json!(temp_tag));
+            
+            let req_temp = QueryRequest {
+                action: "update".to_string(),
+                table: "assets".to_string(),
+                data: Some(Value::Object(temp_update)),
+                r#where: Some(json!({ "id": source_id })),
+                ..Default::default()
+            };
+            
+            if let Err(e) = check_resp(api_client.query(&req_temp)) {
+                self.error = Some(format!("Failed to set temp tag: {}", e));
+                self.step = ReplacementStep::ConfigureOptions;
+                return;
+            }
+            
+            // Step B: Replacement -> Source Tag (and other updates)
+            let mut replacement_update = serde_json::Map::new();
+            replacement_update.insert("asset_tag".to_string(), json!(source_tag));
+            
+            // Set previously_was to point to the old item's numeric ID
+            if let Some(nid) = source_numeric_id {
+                replacement_update.insert("previously_was".to_string(), json!(nid));
+            }
+
+            // Copy Location
+            if self.copy_location {
+                if let Some(zone_id) = source.get("zone_id").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("zone_id".to_string(), json!(zone_id));
+                }
+                if let Some(zone_plus) = source.get("zone_plus").and_then(|v| v.as_str()) {
+                    replacement_update.insert("zone_plus".to_string(), json!(zone_plus));
+                }
+                if let Some(zone_note) = source.get("zone_note").and_then(|v| v.as_str()) {
+                    replacement_update.insert("zone_note".to_string(), json!(zone_note));
+                }
+            }
+
+            // Copy Belongs To
+            if self.copy_belongs_to {
+                if let Some(belongs_to) = source.get("belongs_to_item").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("belongs_to_item".to_string(), json!(belongs_to));
+                }
+            }
+
+            // Copy Assignments (Borrower)
+            if self.copy_assignments {
+                if let Some(borrower_id) = source.get("current_borrower_id").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("current_borrower_id".to_string(), json!(borrower_id));
+                    if let Some(due_date) = source.get("due_date").and_then(|v| v.as_str()) {
+                        replacement_update.insert("due_date".to_string(), json!(due_date));
+                    }
+                    if let Some(lending_status) = source.get("lending_status").and_then(|v| v.as_str()) {
+                        replacement_update.insert("lending_status".to_string(), json!(lending_status));
+                    }
+                }
+            }
+            
+            let req_replacement = QueryRequest {
+                action: "update".to_string(),
+                table: "assets".to_string(),
+                data: Some(Value::Object(replacement_update)),
+                r#where: Some(json!({ "id": replacement_id })),
+                ..Default::default()
+            };
+            
+            if let Err(e) = check_resp(api_client.query(&req_replacement)) {
+                self.error = Some(format!("Failed to update replacement item: {}", e));
+                // Try to revert temp tag? For now just error.
+                self.step = ReplacementStep::ConfigureOptions;
+                return;
+            }
+            
+            // Step C: Source (Temp) -> Replacement Tag (and status update)
+            let mut source_final_update = serde_json::Map::new();
+            source_final_update.insert("asset_tag".to_string(), json!(replacement_tag));
+            source_final_update.insert("status".to_string(), json!(self.new_status_for_old_item));
+            
+            let req_source_final = QueryRequest {
+                action: "update".to_string(),
+                table: "assets".to_string(),
+                data: Some(Value::Object(source_final_update)),
+                r#where: Some(json!({ "id": source_id })),
+                ..Default::default()
+            };
+            
+            if let Err(e) = check_resp(api_client.query(&req_source_final)) {
+                self.error = Some(format!("Failed to finalize old item update: {}", e));
+                self.step = ReplacementStep::ConfigureOptions;
+                return;
+            }
+            
+        } else {
+            // No tag swap, just simple updates
+            
+            // 1. Update OLD item (Source)
+            let mut source_update = serde_json::Map::new();
+            source_update.insert("status".to_string(), json!(self.new_status_for_old_item));
+            
+            let req_source = QueryRequest {
+                action: "update".to_string(),
+                table: "assets".to_string(),
+                data: Some(Value::Object(source_update)),
+                r#where: Some(json!({ "id": source_id })),
+                ..Default::default()
+            };
+            
+            if let Err(e) = check_resp(api_client.query(&req_source)) {
+                self.error = Some(format!("Failed to update old item: {}", e));
+                self.step = ReplacementStep::ConfigureOptions;
+                return;
+            }
+            
+            // 2. Update NEW item (Replacement)
+            let mut replacement_update = serde_json::Map::new();
+            
+            if let Some(nid) = source_numeric_id {
+                replacement_update.insert("previously_was".to_string(), json!(nid));
+            }
+
+            // Copy Location
+            if self.copy_location {
+                if let Some(zone_id) = source.get("zone_id").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("zone_id".to_string(), json!(zone_id));
+                }
+                if let Some(zone_plus) = source.get("zone_plus").and_then(|v| v.as_str()) {
+                    replacement_update.insert("zone_plus".to_string(), json!(zone_plus));
+                }
+                if let Some(zone_note) = source.get("zone_note").and_then(|v| v.as_str()) {
+                    replacement_update.insert("zone_note".to_string(), json!(zone_note));
+                }
+            }
+
+            // Copy Belongs To
+            if self.copy_belongs_to {
+                if let Some(belongs_to) = source.get("belongs_to_item").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("belongs_to_item".to_string(), json!(belongs_to));
+                }
+            }
+
+            // Copy Assignments (Borrower)
+            if self.copy_assignments {
+                if let Some(borrower_id) = source.get("current_borrower_id").and_then(|v| v.as_i64()) {
+                    replacement_update.insert("current_borrower_id".to_string(), json!(borrower_id));
+                    if let Some(due_date) = source.get("due_date").and_then(|v| v.as_str()) {
+                        replacement_update.insert("due_date".to_string(), json!(due_date));
+                    }
+                    if let Some(lending_status) = source.get("lending_status").and_then(|v| v.as_str()) {
+                        replacement_update.insert("lending_status".to_string(), json!(lending_status));
+                    }
+                }
+            }
+            
+            if !replacement_update.is_empty() {
+                let req_replacement = QueryRequest {
+                    action: "update".to_string(),
+                    table: "assets".to_string(),
+                    data: Some(Value::Object(replacement_update)),
+                    r#where: Some(json!({ "id": replacement_id })),
+                    ..Default::default()
+                };
+                
+                if let Err(e) = check_resp(api_client.query(&req_replacement)) {
+                    self.error = Some(format!("Failed to update new item: {}", e));
+                    self.step = ReplacementStep::ConfigureOptions;
+                    return;
+                }
+            }
+        }
+        
+        // 3. Print if requested
+        if self.print_label {
+            // We need to trigger print. 
+            // Since we don't have direct access to printer manager here easily without passing it down,
+            // we might need to signal the parent view to print.
+            // Or we can use the `print_dialog` if we had access.
+            // For now, let's just log it or maybe we can return a "PrintRequired" signal.
+        }
+        
+        self.is_open = false;
+        // Signal success?
+    }
+}
+
+impl Default for QueryRequest {
+    fn default() -> Self {
+        Self {
+            action: "select".to_string(),
+            table: "assets".to_string(),
+            columns: None,
+            data: None,
+            r#where: None,
+            filter: None,
+            order_by: None,
+            limit: None,
+            offset: None,
+            joins: None,
+        }
+    }
+}

+ 6 - 1
src/ui/app.rs

@@ -492,7 +492,12 @@ impl BeepZoneApp {
             let (tx, rx) = mpsc::channel();
             self.health_check_rx = Some(rx);
             self.health_check_in_progress = true;
-            self.server_status = ServerStatus::Checking;
+            
+            // Only show "Checking..." if we aren't already connected to avoid UI flickering
+            if !matches!(self.server_status, ServerStatus::Connected) {
+                self.server_status = ServerStatus::Checking;
+            }
+            
             self.last_health_check = std::time::Instant::now();
 
             std::thread::spawn(move || {

+ 116 - 5
src/ui/audits.rs

@@ -28,6 +28,11 @@ pub struct AuditsView {
     pending_task_delete_id: Option<i64>,
     pending_task_delete_name: Option<String>,
     task_editor: AuditTaskEditor,
+    
+    // Autocomplete
+    available_zones: Vec<Value>,
+    autocomplete_open: bool,
+    last_popup_rect: Option<egui::Rect>,
 }
 
 impl AuditsView {
@@ -68,6 +73,9 @@ impl AuditsView {
             pending_task_delete_id: None,
             pending_task_delete_name: None,
             task_editor: AuditTaskEditor::new(),
+            available_zones: Vec::new(),
+            autocomplete_open: false,
+            last_popup_rect: None,
         }
     }
 
@@ -90,6 +98,17 @@ impl AuditsView {
             }
         }
 
+        // Load zones regardless of audit load success (for autocomplete)
+        match crate::core::tables::get_all_zones_with_filter(api, None) {
+            Ok(zones) => {
+                log::info!("Loaded {} zones for autocomplete", zones.len());
+                self.available_zones = zones;
+            }
+            Err(err) => {
+                log::warn!("Failed to load zones for autocomplete: {}", err);
+            }
+        }
+
         if self.last_error.is_none() {
             match get_recent_audit_logs(api, Some(200)) {
                 Ok(rows) => {
@@ -140,10 +159,11 @@ impl AuditsView {
                 let needs_error_margin = self.start_error.is_some();
                 let needs_progress_msg = self.workflow.is_active();
 
-                if !needs_error_margin {
-                    let extra = if needs_progress_msg { 16.0 } else { 8.0 };
-                    ui.set_max_height(control_height + extra);
-                }
+                // Always constrain height to prevent "black box growing too large"
+                // The "goofy implementation" was skipping this on error, causing the growth.
+                let base_extra = if needs_progress_msg { 16.0 } else { 8.0 };
+                let error_extra = if needs_error_margin { 24.0 } else { 0.0 };
+                ui.set_max_height(control_height + base_extra + error_extra);
 
                 if self.workflow.is_active() {
                     ui.colored_label(
@@ -169,6 +189,88 @@ impl AuditsView {
                             .desired_width(input_w),
                     );
 
+                    if text_resp.gained_focus() {
+                        self.autocomplete_open = true;
+                    }
+
+                    // Handle closing logic
+                    if self.autocomplete_open && text_resp.lost_focus() {
+                        let pointer_pos = ui.input(|i| i.pointer.interact_pos());
+                        let clicked_popup = if let (Some(pos), Some(rect)) = (pointer_pos, self.last_popup_rect) {
+                            rect.contains(pos)
+                        } else {
+                            false
+                        };
+                        
+                        if !clicked_popup {
+                            self.autocomplete_open = false;
+                        }
+                    }
+
+                    // Autocomplete popup
+                    if self.autocomplete_open && !self.available_zones.is_empty() {
+                        let input_lower = self.zone_code_input.to_lowercase();
+                        
+                        let suggestions: Vec<_> = self.available_zones.iter()
+                            .filter_map(|z| {
+                                let code = z.get("zone_code").and_then(|v| v.as_str())?;
+                                let name = z.get("name").and_then(|v| v.as_str()).unwrap_or("");
+                                
+                                // Match against code or name, or show all if empty
+                                if input_lower.is_empty() 
+                                    || code.to_lowercase().contains(&input_lower) 
+                                    || name.to_lowercase().contains(&input_lower) 
+                                {
+                                    Some((code.to_string(), name.to_string()))
+                                } else {
+                                    None
+                                }
+                            })
+                            .take(8)
+                            .collect();
+
+                        if !suggestions.is_empty() {
+                            let popup_id = ui.make_persistent_id("zone_autocomplete_popup");
+                            let below = text_resp.rect.left_bottom();
+                            
+                            let mut popup_rect = egui::Rect::NOTHING;
+
+                            egui::Area::new(popup_id)
+                                .fixed_pos(below)
+                                .order(egui::Order::Foreground)
+                                .show(ui.ctx(), |ui| {
+                                    let frame = egui::Frame::popup(ui.style())
+                                        .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color));
+                                    
+                                    let response = frame.show(ui, |ui| {
+                                            ui.set_min_width(input_w);
+                                            ui.set_max_width(input_w);
+                                            
+                                            for (code, name) in suggestions {
+                                                let label = if name.is_empty() { 
+                                                    code.clone() 
+                                                } else { 
+                                                    format!("{} - {}", code, name) 
+                                                };
+                                                
+                                                if ui.selectable_label(false, &label).clicked() {
+                                                    self.zone_code_input = code;
+                                                    self.autocomplete_open = false;
+                                                    ui.ctx().request_repaint();
+                                                }
+                                            }
+                                        });
+                                    popup_rect = response.response.rect;
+                                });
+                            
+                            self.last_popup_rect = Some(popup_rect);
+                        } else {
+                            self.last_popup_rect = None;
+                        }
+                    } else {
+                        self.last_popup_rect = None;
+                    }
+
                     let disable_new = self.workflow.is_active();
                     let start_zone_clicked_button = ui
                         .add_enabled(
@@ -185,10 +287,12 @@ impl AuditsView {
                         .clicked();
 
                     let start_zone_pressed_enter =
-                        text_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
+                        (text_resp.lost_focus() || text_resp.has_focus()) 
+                        && ui.input(|i| i.key_pressed(egui::Key::Enter));
                     let start_zone_clicked = start_zone_clicked_button || start_zone_pressed_enter;
 
                     if start_zone_clicked {
+                        // ui.memory_mut(|m| m.close_popup(ui.make_persistent_id("zone_autocomplete")));
                         if let Some(user_id) = current_user_id {
                             let code = self.zone_code_input.trim();
                             if code.is_empty() {
@@ -297,6 +401,13 @@ impl AuditsView {
         api_client: Option<&ApiClient>,
         current_user_id: Option<i32>,
     ) {
+        // Auto-load if not loaded
+        if !self.init_loaded {
+            if let Some(api) = api_client {
+                self.load(api);
+            }
+        }
+
         egui::ScrollArea::vertical()
             .auto_shrink([false; 2])
             .show(ui, |ui| {

+ 100 - 2
src/ui/inventory.rs

@@ -1,7 +1,9 @@
+use crate::core::components::history::HistoryWindow;
 use crate::api::ApiClient;
 use crate::core::components::help::{show_help_window, HelpWindowOptions};
 use crate::core::workflows::borrow_flow::{BorrowFlow, BorrowStep};
 use crate::core::workflows::return_flow::{ReturnFlow, ReturnStep};
+use crate::core::workflows::ReplacementWorkflow;
 use crate::core::{
     components::form_builder::FormBuilder, components::interactions::ConfirmDialog,
     workflows::AddFromTemplateWorkflow, AssetFieldBuilder, AssetOperations, ColumnConfig,
@@ -42,10 +44,14 @@ pub struct InventoryView {
     add_from_template_workflow: AddFromTemplateWorkflow,
     borrow_flow: BorrowFlow,
     return_flow: ReturnFlow,
+    replacement_flow: ReplacementWorkflow,
 
     // Help
     show_help: bool,
     help_cache: CommonMarkCache,
+
+    // History
+    history_window: Option<HistoryWindow>,
 }
 
 impl InventoryView {
@@ -57,6 +63,15 @@ impl InventoryView {
             ColumnConfig::new("Numeric ID", "asset_numeric_id")
                 .with_width(100.0)
                 .hidden(),
+            ColumnConfig::new("Tag Gen String", "tag_generation_string")
+                .with_width(150.0)
+                .hidden(),
+            ColumnConfig::new("Belongs To", "belongs_to_item")
+                .with_width(100.0)
+                .hidden(),
+            ColumnConfig::new("Previously Was", "previously_was")
+                .with_width(100.0)
+                .hidden(),
             ColumnConfig::new("Type", "asset_type").with_width(60.0),
             ColumnConfig::new("Name", "name").with_width(180.0),
             ColumnConfig::new("Category", "category_name").with_width(90.0),
@@ -167,8 +182,10 @@ impl InventoryView {
             add_from_template_workflow: AddFromTemplateWorkflow::new(),
             borrow_flow: BorrowFlow::new(),
             return_flow: ReturnFlow::new(),
+            replacement_flow: ReplacementWorkflow::new(),
             show_help: false,
             help_cache: CommonMarkCache::default(),
+            history_window: None,
         }
     }
 
@@ -481,10 +498,12 @@ impl InventoryView {
             match action {
                 DeferredAction::DoubleClick(asset) => {
                     log::info!(
-                        "Processing double-click edit for asset: {:?}",
+                        "Processing double-click history for asset: {:?}",
                         asset.get("name")
                     );
-                    self.open_easy_edit_with(&asset, api_client);
+                    if let Some(client) = api_client {
+                        self.history_window = Some(HistoryWindow::new(&asset, client));
+                    }
                 }
                 DeferredAction::ContextClone(asset) => {
                     log::info!(
@@ -544,6 +563,15 @@ impl InventoryView {
                     }
                     self.advanced_edit_dialog.open(&asset);
                 }
+                DeferredAction::ContextHistory(asset) => {
+                    log::info!(
+                        "Processing context menu history for asset: {:?}",
+                        asset.get("name")
+                    );
+                    if let Some(client) = api_client {
+                        self.history_window = Some(HistoryWindow::new(&asset, client));
+                    }
+                }
                 DeferredAction::ContextDelete(asset) => {
                     if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) {
                         log::info!("Processing context menu delete for asset ID: {}", id);
@@ -606,6 +634,15 @@ impl InventoryView {
                     );
                     self.open_print_dialog(&asset, api_client, true, session_manager);
                 }
+                DeferredAction::ContextReplace(asset) => {
+                    log::info!(
+                        "Processing context menu replace for asset: {:?}",
+                        asset.get("name")
+                    );
+                    if let Some(client) = api_client {
+                        self.replacement_flow.start(asset, client);
+                    }
+                }
             }
         }
     }
@@ -996,6 +1033,9 @@ impl InventoryView {
                                         if matches!(
                                             column.field.as_str(),
                                             "id" | "asset_numeric_id"
+                                                | "tag_generation_string"
+                                                | "belongs_to_item"
+                                                | "previously_was"
                                                 | "supplier_name"
                                                 | "no_scan"
                                                 | "notes"
@@ -1124,6 +1164,16 @@ impl InventoryView {
             }
         }
 
+        // History window
+        if let Some(window) = &mut self.history_window {
+            if let Some(client) = api_client {
+                window.show(ui.ctx(), client);
+            }
+            if !window.open {
+                self.history_window = None;
+            }
+        }
+
         if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ui.ctx()) {
             if let Some(client) = api_client {
                 // Check if user requested label printing after add
@@ -1293,6 +1343,13 @@ impl InventoryView {
             } else if self.return_flow.success_message.is_some() {
                 self.load_assets(client, limit, None, None);
             }
+            
+            // Replacement flow
+            if self.replacement_flow.show(ui.ctx(), client).is_some() {
+                // If it returns Some, it means it finished or changed state significantly
+                // We can reload assets if needed
+                self.load_assets(client, limit, None, None);
+            }
         }
 
         // Print dialog
@@ -1385,6 +1442,19 @@ impl InventoryView {
             self.advanced_edit_dialog.open(&asset);
         }
 
+        if let Some(asset) = ui
+            .ctx()
+            .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_replace")))
+        {
+            log::info!(
+                "Processing context menu replace for asset: {:?}",
+                asset.get("name")
+            );
+            if let Some(client) = api_client {
+                self.replacement_flow.start(asset, client);
+            }
+        }
+
         if let Some(asset) = ui
             .ctx()
             .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_delete")))
@@ -1771,7 +1841,9 @@ enum DeferredAction {
     ContextReturn(Value),
     ContextPrintLabel(Value),
     ContextAdvancedPrint(Value),
+    ContextReplace(Value),
     ContextClone(Value),
+    ContextHistory(Value),
 }
 
 // Temporary event handler that collects actions for later processing
@@ -1826,6 +1898,32 @@ impl<'a> TableEventHandler<Value> for TempInventoryEventHandler<'a> {
             ui.close();
         }
 
+        if ui
+            .button(format!("{} Replace Item", egui_phosphor::regular::ARROWS_LEFT_RIGHT))
+            .clicked()
+        {
+            log::info!(
+                "Context menu replace clicked for asset: {:?}",
+                item.get("name")
+            );
+            self.deferred_actions
+                .push(DeferredAction::ContextReplace(item.clone()));
+            ui.close();
+        }
+
+        if ui
+            .button(format!("{} View History", egui_phosphor::regular::CLOCK_COUNTER_CLOCKWISE))
+            .clicked()
+        {
+            log::info!(
+                "Context menu history clicked for asset: {:?}",
+                item.get("name")
+            );
+            self.deferred_actions
+                .push(DeferredAction::ContextHistory(item.clone()));
+            ui.close();
+        }
+
         ui.separator();
 
         if ui