Преглед на файлове

Merge branch 'the-crapshoot' of crt/BeepZone-Client-eGUI-Emo into master

lgtm
crt преди 1 месец
родител
ревизия
181b4a568a

+ 1 - 0
Cargo.toml

@@ -22,6 +22,7 @@ tokio = { version = "1.40", features = ["full"] }
 # jayson derulo
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
+toml = "0.8"
 
 # basics
 chrono = { version = "0.4", features = ["serde"] }

+ 17 - 0
docs/todo.md

@@ -0,0 +1,17 @@
+# Important :
+
+
+
+
+## Part 3 :
+- make it actuall support backends RBAC lol
+-- kinda does now i guess ?
+
+## 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 : 

+ 26 - 0
kiosk.toml

@@ -0,0 +1,26 @@
+[kiosk]
+server_url = "http://localhost:5777"
+username = "kiosk"
+password = "kiosk"
+
+[kiosk.filter]
+hide_self = true
+role_whitelist_enabled = false
+role_whitelist = []
+role_blacklist_enabled = true
+role_blacklist = ["Kiosk", "User"]
+username_whitelist_enabled = false
+username_whitelist = []
+username_blacklist_enabled = false
+username_blacklist = []
+
+[kiosk.ui]
+title = "BeepZone  Kiosk"
+enable_osk = true
+enable_rfid = true
+fullscreen = true
+button_height = 80.0
+font_size = 24.0
+minimum_power_level_for_full_ui = 50
+timeout_seconds = 240
+enable_full_osk_button = true

+ 14 - 12
src/api.rs

@@ -31,6 +31,10 @@ impl ApiClient {
         })
     }
 
+    pub fn get_token(&self) -> Option<String> {
+        self.token.clone()
+    }
+
     fn flag_timeout_signal(&self) {
         self.db_timeout_flag.store(true, Ordering::SeqCst);
     }
@@ -128,8 +132,7 @@ impl ApiClient {
     }
 
     /// Login with PIN
-    #[allow(dead_code)]
-    pub fn login_pin(&self, username: &str, pin: &str) -> Result<ApiResponse<LoginResponse>> {
+    pub fn login_pin(&self, username: &str, pin: &str) -> Result<LoginResponse> {
         let url = format!("{}/auth/login", self.base_url);
         let body = LoginRequest {
             method: "pin".to_string(),
@@ -144,7 +147,7 @@ impl ApiClient {
             "Failed to send PIN login request",
         )?;
 
-        let result: ApiResponse<LoginResponse> =
+        let result: LoginResponse =
             response.json().context("Failed to parse login response")?;
         self.observe_response_error(&result.error);
 
@@ -152,8 +155,7 @@ impl ApiClient {
     }
 
     /// Login with token/RFID string
-    #[allow(dead_code)]
-    pub fn login_token(&self, login_string: &str) -> Result<ApiResponse<LoginResponse>> {
+    pub fn login_token(&self, login_string: &str) -> Result<LoginResponse> {
         let url = format!("{}/auth/login", self.base_url);
         let body = LoginRequest {
             method: "token".to_string(),
@@ -168,7 +170,7 @@ impl ApiClient {
             "Failed to send token login request",
         )?;
 
-        let result: ApiResponse<LoginResponse> =
+        let result: LoginResponse =
             response.json().context("Failed to parse login response")?;
         self.observe_response_error(&result.error);
 
@@ -257,14 +259,14 @@ impl ApiClient {
 
     /// Get current user's permissions
     #[allow(dead_code)]
-    pub fn get_permissions(&self) -> Result<ApiResponse<PermissionsResponse>> {
+    pub fn get_permissions(&self) -> Result<PermissionsResponse> {
         let url = format!("{}/permissions", self.base_url);
         let response = self.send_request(
             self.make_authorized_request(reqwest::Method::GET, &url)?,
             "Failed to get permissions",
         )?;
 
-            let result: ApiResponse<PermissionsResponse> = response.json()?;
+        let result: PermissionsResponse = response.json()?;
         self.observe_response_error(&result.error);
         Ok(result)
     }
@@ -274,7 +276,7 @@ impl ApiClient {
     pub fn get_preferences(
         &self,
         user_id: Option<i32>,
-    ) -> Result<ApiResponse<PreferencesResponse>> {
+    ) -> Result<PreferencesResponse> {
         let url = format!("{}/preferences", self.base_url);
         let body = PreferencesRequest {
             action: "get".to_string(),
@@ -288,7 +290,7 @@ impl ApiClient {
             "Failed to get preferences",
         )?;
 
-            let result: ApiResponse<PreferencesResponse> = response.json()?;
+        let result: PreferencesResponse = response.json()?;
         self.observe_response_error(&result.error);
         Ok(result)
     }
@@ -299,7 +301,7 @@ impl ApiClient {
         &self,
         values: serde_json::Value,
         user_id: Option<i32>,
-    ) -> Result<ApiResponse<PreferencesResponse>> {
+    ) -> Result<PreferencesResponse> {
         let url = format!("{}/preferences", self.base_url);
         let body = PreferencesRequest {
             action: "set".to_string(),
@@ -313,7 +315,7 @@ impl ApiClient {
             "Failed to set preferences",
         )?;
 
-            let result: ApiResponse<PreferencesResponse> = response.json()?;
+            let result: PreferencesResponse = response.json()?;
             self.observe_response_error(&result.error);
             Ok(result)
     }

+ 56 - 0
src/config.rs

@@ -41,3 +41,59 @@ pub struct WebPreferences {
 pub struct MobilePreferences {
     pub scan_mode: String,
 }
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct KioskConfig {
+    pub kiosk: KioskSettings,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct KioskSettings {
+    pub server_url: String,
+    pub username: String,
+    pub password: String,
+    #[serde(default)]
+    pub filter: KioskFilterSettings,
+    #[serde(default)]
+    pub ui: KioskUiSettings,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct KioskFilterSettings {
+    pub hide_self: bool,
+    pub role_whitelist_enabled: bool,
+    pub role_whitelist: Vec<String>,
+    pub role_blacklist_enabled: bool,
+    pub role_blacklist: Vec<String>,
+    pub username_whitelist_enabled: bool,
+    pub username_whitelist: Vec<String>,
+    pub username_blacklist_enabled: bool,
+    pub username_blacklist: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct KioskUiSettings {
+    #[serde(default = "default_kiosk_title")]
+    pub title: String,
+    pub enable_osk: bool,
+    #[serde(default)]
+    pub enable_rfid: bool,
+    #[serde(default)]
+    pub fullscreen: bool,
+    pub button_height: f32,
+    pub font_size: f32,
+    #[serde(default = "default_min_power")]
+    pub minimum_power_level_for_full_ui: i32,
+    #[serde(default)]
+    pub timeout_seconds: Option<u64>,
+    #[serde(default)]
+    pub enable_full_osk_button: bool,
+}
+
+fn default_kiosk_title() -> String {
+    "BeepZone Kiosk".to_string()
+}
+
+fn default_min_power() -> i32 {
+    50
+}

+ 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,
+        }
+    }
+}

+ 232 - 0
src/kioskui/app.rs

@@ -0,0 +1,232 @@
+use eframe::egui;
+use std::sync::Arc;
+use tokio::sync::Mutex;
+
+use crate::api::ApiClient;
+use crate::config::KioskSettings;
+use crate::models::{UserInfo, LoginResponse};
+use crate::session::SessionManager;
+use crate::ui::app::BeepZoneApp;
+use super::login::{KioskLoginView, LoginResult};
+
+pub struct KioskApp {
+    // Session management
+    session_manager: Arc<Mutex<SessionManager>>,
+    api_client: Option<ApiClient>,
+    
+    // Kiosk state
+    config: KioskSettings,
+    
+    // UI components
+    login_view: KioskLoginView,
+    full_ui_app: Option<BeepZoneApp>,
+    
+    // State
+    is_initialized: bool,
+    current_user: Option<UserInfo>, // The Kiosk User
+    session_user: Option<UserInfo>, // The User currently logged in via Kiosk
+    session_token: Option<String>,  // The Token of the User currently logged in via Kiosk
+    error_message: Option<String>,
+    show_full_ui: bool,
+    last_interaction: std::time::Instant,
+}
+
+impl KioskApp {
+    pub fn new(
+        cc: &eframe::CreationContext<'_>,
+        session_manager: Arc<Mutex<SessionManager>>,
+        config: KioskSettings,
+    ) -> Self {
+        let login_view = KioskLoginView::new(config.filter.clone(), config.ui.clone());
+        let mut full_ui_app = BeepZoneApp::new(cc, session_manager.clone());
+        full_ui_app.is_kiosk_mode = true;
+        full_ui_app.enable_full_osk_button = config.ui.enable_full_osk_button;
+        
+        Self {
+            session_manager,
+            api_client: None,
+            config,
+            login_view,
+            full_ui_app: Some(full_ui_app),
+            is_initialized: false,
+            current_user: None,
+            session_user: None,
+            session_token: None,
+            error_message: None,
+            show_full_ui: false,
+            last_interaction: std::time::Instant::now(),
+        }
+    }
+
+    fn initialize_session(&mut self) {
+        if self.is_initialized {
+            return;
+        }
+
+        log::info!("Initializing Kiosk session for user: {}", self.config.username);
+        
+        // Create API client
+        let mut client = match ApiClient::new(self.config.server_url.clone()) {
+            Ok(c) => c,
+            Err(e) => {
+                self.error_message = Some(format!("Failed to connect to server: {}", e));
+                return;
+            }
+        };
+
+        // Attempt login
+        match client.login_password(&self.config.username, &self.config.password) {
+            Ok(response) => {
+                if response.success {
+                    if let (Some(token), Some(user)) = (response.token, response.user) {
+                        log::info!("Kiosk login successful");
+                        client.set_token(token);
+                        self.api_client = Some(client);
+                        self.current_user = Some(user);
+                        self.is_initialized = true;
+                        self.error_message = None;
+                        
+                        // Initialize login view with client
+                        if let Some(client) = &self.api_client {
+                            self.login_view.refresh_users(client, self.current_user.as_ref());
+                        }
+                    } else {
+                        self.error_message = Some("Login successful but missing token or user data".to_string());
+                    }
+                } else {
+                    self.error_message = Some("Login failed: Invalid credentials".to_string());
+                }
+            }
+            Err(e) => {
+                self.error_message = Some(format!("Login error: {}", e));
+            }
+        }
+    }
+}
+
+impl eframe::App for KioskApp {
+    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
+        // Check for interaction (clicks or key presses, ignore mouse moves to prevent drift issues)
+        let has_interaction = ctx.input(|i| {
+            i.pointer.any_pressed() || 
+            i.events.iter().any(|e| matches!(e, egui::Event::Key{..} | egui::Event::Text(_)))
+        });
+
+        if has_interaction {
+            self.last_interaction = std::time::Instant::now();
+        }
+
+        // Check timeout
+        if let Some(timeout) = self.config.ui.timeout_seconds {
+            if self.session_user.is_some() && self.last_interaction.elapsed().as_secs() > timeout {
+                // Timeout!
+                self.session_user = None;
+                self.session_token = None;
+                self.show_full_ui = false;
+                if let Some(app) = &mut self.full_ui_app {
+                    app.handle_logout(); // Ensure app state is cleared
+                    app.should_exit_to_kiosk = false; // We handled it
+                }
+                self.login_view.reset();
+            }
+        }
+
+        // Handle Full UI Mode
+        if self.show_full_ui {
+            if let Some(app) = &mut self.full_ui_app {
+                app.update(ctx, frame);
+                
+                // Check if we should exit back to kiosk
+                if app.should_exit_to_kiosk {
+                    self.show_full_ui = false;
+                    app.should_exit_to_kiosk = false;
+                    self.session_user = None; // Also sign out of kiosk session
+                    self.session_token = None;
+                    self.login_view.reset();
+                }
+                return;
+            }
+        }
+
+        // Initialize on first frame
+        if !self.is_initialized && self.error_message.is_none() {
+            self.initialize_session();
+        }
+
+        // Full screen container
+        egui::CentralPanel::default().show(ctx, |ui| {
+            // Clone error message to avoid borrow checker issues
+            let error_msg = self.error_message.clone();
+            
+            if let Some(error) = error_msg {
+                // Error state
+                ui.centered_and_justified(|ui| {
+                    ui.vertical_centered(|ui| {
+                        ui.heading(egui::RichText::new("Kiosk Initialization Failed").color(egui::Color32::RED));
+                        ui.add_space(10.0);
+                        ui.label(error);
+                        ui.add_space(20.0);
+                        if ui.button("Retry").clicked() {
+                            self.error_message = None;
+                            self.is_initialized = false;
+                        }
+                    });
+                });
+            } else if !self.is_initialized {
+                // Loading state
+                ui.centered_and_justified(|ui| {
+                    ui.spinner();
+                });
+            } else {
+                // Main Kiosk UI
+                if let Some(client) = &self.api_client {
+                    let session_user = self.session_user.clone();
+                    if let Some(user) = session_user {
+                        // Logged In View
+                        ui.centered_and_justified(|ui| {
+                            ui.vertical_centered(|ui| {
+                                ui.heading(egui::RichText::new("Yay it works").size(48.0).color(egui::Color32::GREEN));
+                                ui.add_space(20.0);
+                                ui.label(egui::RichText::new(format!("Welcome, {}", user.name)).size(32.0));
+                                ui.add_space(40.0);
+                                
+                                if ui.add_sized(egui::vec2(200.0, 80.0), egui::Button::new(egui::RichText::new("Sign Out").size(24.0))).clicked() {
+                                    self.session_user = None;
+                                    self.session_token = None;
+                                    self.login_view.reset();
+                                }
+
+                                if user.power >= self.config.ui.minimum_power_level_for_full_ui {
+                                    ui.add_space(20.0);
+                                    if ui.add_sized(egui::vec2(200.0, 60.0), egui::Button::new(egui::RichText::new("Show Full UI").size(20.0))).clicked() {
+                                        if let Some(app) = &mut self.full_ui_app {
+                                            // Construct a LoginResponse to simulate a successful login
+                                            let login_response = LoginResponse {
+                                                success: true,
+                                                token: self.session_token.clone(),
+                                                user: Some(user.clone()),
+                                                error: None,
+                                            };
+                                            
+                                            app.handle_login_success(self.config.server_url.clone(), login_response);
+                                            self.show_full_ui = true;
+                                        }
+                                    }
+                                }
+                            });
+                        });
+                    } else {
+                        // Login View
+                        match self.login_view.show(ui, client) {
+                            LoginResult::Success(user, token) => {
+                                self.session_user = Some(user);
+                                self.session_token = Some(token);
+                            }
+                            LoginResult::None => {}
+                        }
+                    }
+                }
+            }
+        });
+    }
+}

+ 421 - 0
src/kioskui/login.rs

@@ -0,0 +1,421 @@
+use eframe::egui;
+use crate::api::ApiClient;
+use crate::config::{KioskFilterSettings, KioskUiSettings};
+use crate::models::{QueryRequest, OrderBy, UserInfo};
+use serde::Deserialize;
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct KioskUser {
+    pub id: i32,
+    pub username: String,
+    pub name: String,
+    pub role_id: i32,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct KioskRole {
+    pub id: i32,
+    pub name: String,
+}
+
+pub enum LoginResult {
+    None,
+    Success(UserInfo, String),
+}
+
+pub struct KioskLoginView {
+    users: Vec<KioskUser>,
+    roles: HashMap<i32, String>,
+    selected_user: Option<KioskUser>,
+    pin_input: String,
+    rfid_input: String,
+    error_message: Option<String>,
+    is_loading: bool,
+    filter_settings: KioskFilterSettings,
+    ui_settings: KioskUiSettings,
+    is_rfid_mode: bool,
+}
+
+impl KioskLoginView {
+    pub fn reset(&mut self) {
+        self.selected_user = None;
+        self.pin_input.clear();
+        self.rfid_input.clear();
+        self.error_message = None;
+        self.is_rfid_mode = false;
+    }
+
+    pub fn new(filter_settings: KioskFilterSettings, ui_settings: KioskUiSettings) -> Self {
+        Self {
+            users: Vec::new(),
+            roles: HashMap::new(),
+            selected_user: None,
+            pin_input: String::new(),
+            rfid_input: String::new(),
+            error_message: None,
+            is_loading: false,
+            filter_settings,
+            ui_settings,
+            is_rfid_mode: false,
+        }
+    }
+
+    pub fn refresh_users(&mut self, client: &ApiClient, current_user: Option<&UserInfo>) {
+        self.is_loading = true;
+        
+        // Fetch Roles first if needed for filtering
+        if self.filter_settings.role_whitelist_enabled || self.filter_settings.role_blacklist_enabled {
+            let role_req = QueryRequest {
+                action: "select".to_string(),
+                table: "roles".to_string(),
+                columns: Some(vec!["id".to_string(), "name".to_string()]),
+                data: None,
+                r#where: None,
+                filter: None,
+                order_by: None,
+                limit: Some(100),
+                offset: None,
+                joins: None,
+            };
+            
+            if let Ok(response) = client.query(&role_req) {
+                if response.success {
+                    if let Some(data) = response.data {
+                        if let Ok(roles) = serde_json::from_value::<Vec<KioskRole>>(data) {
+                            self.roles = roles.into_iter().map(|r| (r.id, r.name)).collect();
+                        }
+                    }
+                }
+            }
+        }
+
+        let request = QueryRequest {
+            action: "select".to_string(),
+            table: "users".to_string(),
+            columns: Some(vec!["id".to_string(), "username".to_string(), "name".to_string(), "role_id".to_string()]),
+            data: None,
+            r#where: None,
+            filter: None,
+            order_by: Some(vec![OrderBy {
+                column: "name".to_string(),
+                direction: "ASC".to_string(),
+            }]),
+            limit: Some(100),
+            offset: None,
+            joins: None,
+        };
+
+        match client.query(&request) {
+            Ok(response) => {
+                if response.success {
+                    if let Some(data) = response.data {
+                        match serde_json::from_value::<Vec<KioskUser>>(data) {
+                            Ok(mut users) => {
+                                // Apply filters
+                                users.retain(|u| {
+                                    // Hide self
+                                    if self.filter_settings.hide_self {
+                                        if let Some(curr) = current_user {
+                                            if u.username == curr.username {
+                                                return false;
+                                            }
+                                        }
+                                    }
+
+                                    // Username Whitelist
+                                    if self.filter_settings.username_whitelist_enabled {
+                                        if !self.filter_settings.username_whitelist.contains(&u.username) {
+                                            return false;
+                                        }
+                                    }
+
+                                    // Username Blacklist
+                                    if self.filter_settings.username_blacklist_enabled {
+                                        if self.filter_settings.username_blacklist.contains(&u.username) {
+                                            return false;
+                                        }
+                                    }
+
+                                    // Role Filtering
+                                    if self.filter_settings.role_whitelist_enabled || self.filter_settings.role_blacklist_enabled {
+                                        if let Some(role_name) = self.roles.get(&u.role_id) {
+                                            if self.filter_settings.role_whitelist_enabled {
+                                                if !self.filter_settings.role_whitelist.contains(role_name) {
+                                                    return false;
+                                                }
+                                            }
+                                            if self.filter_settings.role_blacklist_enabled {
+                                                if self.filter_settings.role_blacklist.contains(role_name) {
+                                                    return false;
+                                                }
+                                            }
+                                        }
+                                    }
+
+                                    true
+                                });
+
+                                self.users = users;
+                                self.error_message = None;
+                            }
+                            Err(e) => {
+                                self.error_message = Some(format!("Failed to parse users: {}", e));
+                            }
+                        }
+                    } else {
+                        self.users.clear();
+                    }
+                } else {
+                    self.error_message = Some(format!("Failed to fetch users: {}", response.error.unwrap_or_default()));
+                }
+            }
+            Err(e) => {
+                self.error_message = Some(format!("Network error: {}", e));
+            }
+        }
+
+        self.is_loading = false;
+    }
+
+    pub fn show(&mut self, ui: &mut egui::Ui, client: &ApiClient) -> LoginResult {
+        let mut result = LoginResult::None;
+
+        ui.vertical_centered(|ui| {
+            ui.add_space(50.0);
+            ui.heading(egui::RichText::new(&self.ui_settings.title).size(32.0));
+            ui.add_space(30.0);
+
+            if self.is_rfid_mode {
+                result = self.show_rfid_entry(ui, client);
+            } else if self.selected_user.is_none() {
+                self.show_user_selection(ui);
+            } else {
+                result = self.show_pin_entry(ui, client);
+            }
+        });
+
+        result
+    }
+
+    fn show_user_selection(&mut self, ui: &mut egui::Ui) {
+        ui.label(egui::RichText::new("Select User").size(self.ui_settings.font_size));
+        ui.add_space(20.0);
+
+        if self.is_loading {
+            ui.spinner();
+            return;
+        }
+
+        if let Some(error) = &self.error_message {
+            ui.label(egui::RichText::new(error).color(egui::Color32::RED));
+            ui.add_space(10.0);
+        }
+
+        egui::ScrollArea::vertical().show(ui, |ui| {
+            ui.vertical_centered(|ui| {
+                for user in &self.users {
+                    let btn = egui::Button::new(
+                        egui::RichText::new(&user.name).size(self.ui_settings.font_size * 0.8)
+                    ).min_size(egui::vec2(300.0, self.ui_settings.button_height));
+
+                    if ui.add(btn).clicked() {
+                        self.selected_user = Some(user.clone());
+                        self.pin_input.clear();
+                        self.error_message = None;
+                    }
+                    ui.add_space(10.0);
+                }
+
+                if self.ui_settings.enable_rfid {
+                    ui.add_space(20.0);
+                    let btn = egui::Button::new(
+                        egui::RichText::new("RFID Sign In").size(self.ui_settings.font_size * 0.8)
+                    ).min_size(egui::vec2(300.0, self.ui_settings.button_height));
+                    
+                    if ui.add(btn).clicked() {
+                        self.is_rfid_mode = true;
+                        self.rfid_input.clear();
+                        self.error_message = None;
+                    }
+                }
+            });
+        });
+    }
+
+    fn show_rfid_entry(&mut self, ui: &mut egui::Ui, client: &ApiClient) -> LoginResult {
+        ui.label(egui::RichText::new("Scan RFID Tag").size(self.ui_settings.font_size));
+        ui.add_space(20.0);
+        
+        // Invisible input that keeps focus to capture RFID scanner input
+        let response = ui.add(egui::TextEdit::singleline(&mut self.rfid_input).password(true).desired_width(0.0));
+        
+        // Always request focus
+        response.request_focus();
+
+        // Check for Enter key (scanner usually sends Enter at the end)
+        // Also check if input length > 0 to avoid empty submissions
+        if (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) || 
+           (ui.input(|i| i.key_pressed(egui::Key::Enter)) && !self.rfid_input.is_empty()) {
+            return self.attempt_rfid_login(client);
+        }
+        
+        ui.add_space(20.0);
+        ui.spinner();
+        ui.add_space(20.0);
+        
+        if ui.add_sized(egui::vec2(150.0, self.ui_settings.button_height), egui::Button::new(egui::RichText::new("Cancel").size(self.ui_settings.font_size * 0.8))).clicked() {
+            self.is_rfid_mode = false;
+            self.rfid_input.clear();
+            self.error_message = None;
+        }
+
+        if let Some(error) = &self.error_message {
+            ui.add_space(10.0);
+            ui.label(egui::RichText::new(error).color(egui::Color32::RED));
+        }
+
+        LoginResult::None
+    }
+
+    fn show_pin_entry(&mut self, ui: &mut egui::Ui, client: &ApiClient) -> LoginResult {
+        if let Some(user) = &self.selected_user {
+            ui.label(egui::RichText::new(format!("Hello, {}", user.name)).size(self.ui_settings.font_size));
+            ui.add_space(20.0);
+            
+            ui.label(egui::RichText::new("Enter PIN:").size(self.ui_settings.font_size * 0.8));
+            let response = ui.add(egui::TextEdit::singleline(&mut self.pin_input).password(true).font(egui::FontId::proportional(self.ui_settings.font_size)));
+            
+            // Auto-focus PIN input
+            if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+                return self.attempt_login(client);
+            }
+            
+            ui.add_space(20.0);
+
+            // OSK
+            if self.ui_settings.enable_osk {
+                self.show_osk(ui);
+                ui.add_space(20.0);
+            }
+            
+            let result = ui.horizontal(|ui| {
+                // Align buttons to bottom/center with larger size
+                let btn_size = egui::vec2(150.0, self.ui_settings.button_height);
+                let total_width = btn_size.x * 2.0 + 20.0; // 2 buttons + spacing
+                let available_width = ui.available_width();
+                let margin = (available_width - total_width) / 2.0;
+                
+                if margin > 0.0 {
+                    ui.add_space(margin);
+                }
+                
+                if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new("Back").size(self.ui_settings.font_size * 0.8))).clicked() {
+                    self.selected_user = None;
+                    self.pin_input.clear();
+                    self.error_message = None;
+                }
+                
+                ui.add_space(20.0);
+
+                if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new("Login").size(self.ui_settings.font_size * 0.8))).clicked() {
+                    return self.attempt_login(client);
+                }
+                
+                LoginResult::None
+            }).inner;
+
+            if let Some(error) = &self.error_message {
+                ui.add_space(10.0);
+                ui.label(egui::RichText::new(error).color(egui::Color32::RED));
+            }
+            
+            return result;
+        }
+        LoginResult::None
+    }
+
+    fn show_osk(&mut self, ui: &mut egui::Ui) {
+        let keys = [
+            ["1", "2", "3"],
+            ["4", "5", "6"],
+            ["7", "8", "9"],
+            ["CLR", "0", "DEL"],
+        ];
+
+        let btn_size = egui::vec2(80.0, 80.0); // Large touch targets
+
+        ui.vertical_centered(|ui| {
+            for row in keys {
+                ui.horizontal(|ui| {
+                    // Center the row content
+                    let row_width = btn_size.x * 3.0 + ui.style().spacing.item_spacing.x * 2.0;
+                    let available_width = ui.available_width();
+                    let margin = (available_width - row_width) / 2.0;
+                    if margin > 0.0 {
+                        ui.add_space(margin);
+                    }
+
+                    for key in row {
+                        if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new(key).size(32.0))).clicked() {
+                            match key {
+                                "CLR" => self.pin_input.clear(),
+                                "DEL" => { self.pin_input.pop(); },
+                                _ => self.pin_input.push_str(key),
+                            }
+                        }
+                    }
+                });
+                ui.add_space(10.0);
+            }
+        });
+    }
+
+    fn attempt_login(&mut self, client: &ApiClient) -> LoginResult {
+        if let Some(user) = &self.selected_user {
+            match client.login_pin(&user.username, &self.pin_input) {
+                Ok(response) => {
+                    if response.success {
+                        self.error_message = None;
+                        if let (Some(user), Some(token)) = (response.user, response.token) {
+                            return LoginResult::Success(user, token);
+                        } else {
+                            self.error_message = Some("Login successful but no user data or token returned".to_string());
+                        }
+                    } else {
+                        self.error_message = Some(response.error.unwrap_or_else(|| "Login failed".to_string()));
+                    }
+                }
+                Err(e) => {
+                    self.error_message = Some(format!("Login error: {}", e));
+                }
+            }
+        }
+        LoginResult::None
+    }
+
+    fn attempt_rfid_login(&mut self, client: &ApiClient) -> LoginResult {
+        match client.login_token(&self.rfid_input) {
+            Ok(response) => {
+                if response.success {
+                    self.error_message = None;
+                    self.is_rfid_mode = false;
+                    self.rfid_input.clear();
+                    if let (Some(user), Some(token)) = (response.user, response.token) {
+                        return LoginResult::Success(user, token);
+                    } else {
+                        self.error_message = Some("Login successful but no user data or token returned".to_string());
+                    }
+                } else {
+                    self.error_message = Some(response.error.unwrap_or_else(|| "Login failed".to_string()));
+                    self.rfid_input.clear();
+                }
+            }
+            Err(e) => {
+                self.error_message = Some(format!("Login error: {}", e));
+                self.rfid_input.clear();
+            }
+        }
+        LoginResult::None
+    }
+}

+ 2 - 0
src/kioskui/mod.rs

@@ -0,0 +1,2 @@
+pub mod app;
+pub mod login;

+ 62 - 6
src/main.rs

@@ -1,3 +1,5 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
+
 use eframe::egui;
 use std::sync::Arc;
 use tokio::sync::Mutex;
@@ -5,12 +7,14 @@ use tokio::sync::Mutex;
 mod api;
 mod config;
 mod core;
+mod kioskui;
 mod models;
 mod session;
 mod ui;
 
 use session::SessionManager;
 use ui::app::BeepZoneApp;
+use kioskui::app::KioskApp;
 
 fn main() -> eframe::Result<()> {
     // Initialize logging
@@ -20,12 +24,51 @@ fn main() -> eframe::Result<()> {
 
     log::info!("Starting BeepZone Inventory Management System");
 
+    // Parse command line arguments
+    let args: Vec<String> = std::env::args().collect();
+    let mut kiosk_mode = false;
+    let mut kiosk_config = None;
+
+    if args.len() >= 3 && args[1] == "--kiosk" {
+        kiosk_mode = true;
+        let config_path = &args[2];
+        
+        match std::fs::read_to_string(config_path) {
+            Ok(content) => {
+                match toml::from_str::<config::KioskConfig>(&content) {
+                    Ok(config) => {
+                        log::info!("Starting in Kiosk mode connecting to {}", config.kiosk.server_url);
+                        kiosk_config = Some(config.kiosk);
+                    }
+                    Err(e) => {
+                        log::error!("Failed to parse kiosk config: {}", e);
+                        kiosk_mode = false;
+                    }
+                }
+            }
+            Err(e) => {
+                log::error!("Failed to read kiosk config file: {}", e);
+                kiosk_mode = false;
+            }
+        }
+    }
+
     // Configure egui options
+    let mut viewport_builder = egui::ViewportBuilder::default()
+        .with_inner_size([1280.0, 800.0])
+        .with_min_inner_size([800.0, 600.0])
+        .with_icon(load_icon());
+
+    if kiosk_mode {
+        if let Some(config) = &kiosk_config {
+            if config.ui.fullscreen {
+                viewport_builder = viewport_builder.with_fullscreen(true);
+            }
+        }
+    }
+
     let options = eframe::NativeOptions {
-        viewport: egui::ViewportBuilder::default()
-            .with_inner_size([1280.0, 800.0])
-            .with_min_inner_size([800.0, 600.0])
-            .with_icon(load_icon()),
+        viewport: viewport_builder,
         persist_window: true,
         ..Default::default()
     };
@@ -35,14 +78,27 @@ fn main() -> eframe::Result<()> {
 
     // Run the application
     eframe::run_native(
-        "BeepZone Inventory System",
+        if kiosk_mode { "BeepZone Kiosk" } else { "BeepZone Inventory System" },
         options,
         Box::new(move |cc| {
             // Configure fonts and style
             configure_fonts(&cc.egui_ctx);
             configure_style(&cc.egui_ctx);
 
-            Ok(Box::new(BeepZoneApp::new(cc, session_manager)))
+            if kiosk_mode {
+                if let Some(config) = kiosk_config {
+                    Ok(Box::new(KioskApp::new(
+                        cc,
+                        session_manager,
+                        config,
+                    )))
+                } else {
+                    // Should not happen due to logic above, but fallback
+                    panic!("Kiosk config missing");
+                }
+            } else {
+                Ok(Box::new(BeepZoneApp::new(cc, session_manager)))
+            }
         }),
     )
 }

+ 9 - 4
src/models.rs

@@ -41,14 +41,16 @@ pub struct LoginRequest {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LoginResponse {
     pub success: bool,
-    pub token: String,
-    pub user: UserInfo,
+    pub token: Option<String>,
+    pub user: Option<UserInfo>,
+    pub error: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct UserInfo {
     pub id: i32,
     pub username: String,
+    pub name: String,
     pub role: String,
     pub power: i32,
 }
@@ -68,10 +70,12 @@ pub struct SessionStatus {
 #[allow(dead_code)]
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct PermissionsResponse {
+    pub success: bool,
     pub user: UserInfo,
     pub user_settings_access: String,
     pub permissions: serde_json::Value, // Flexible permissions structure
     pub security_clearance: Option<String>,
+    pub error: Option<String>,
 }
 
 // ============================================================================
@@ -91,8 +95,9 @@ pub struct PreferencesRequest {
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct PreferencesResponse {
-    pub user_id: i32,
-    pub preferences: serde_json::Value,
+    pub success: bool,
+    pub preferences: Option<serde_json::Value>,
+    pub error: Option<String>,
 }
 
 // ============================================================================

+ 2 - 0
src/session.rs

@@ -15,6 +15,8 @@ pub struct SessionData {
     pub remember_username: bool,
     pub saved_username: Option<String>,
     #[serde(default)]
+    pub permissions: Option<serde_json::Value>,
+    #[serde(default)]
     pub default_printer_id: Option<i64>,
     /// Remember last-used printer (may differ from default_printer_id if user overrides per-print)
     #[serde(default)]

+ 275 - 31
src/ui/app.rs

@@ -30,6 +30,7 @@ pub struct BeepZoneApp {
     current_view: AppView,
     previous_view: Option<AppView>,
     current_user: Option<UserInfo>,
+    current_permissions: Option<serde_json::Value>,
 
     // Per-view filter state storage
     view_filter_states: HashMap<AppView, crate::core::components::filter_builder::FilterGroup>,
@@ -56,6 +57,15 @@ pub struct BeepZoneApp {
     // State
     login_success: Option<(String, LoginResponse)>,
     show_about: bool,
+    pub should_exit_to_kiosk: bool,
+    
+    // Kiosk integration
+    pub is_kiosk_mode: bool,
+    pub enable_full_osk_button: bool,
+    pub show_osk: bool,
+    pub osk_shift_mode: bool,
+    last_focused_id: Option<egui::Id>,
+    osk_event_queue: Vec<egui::Event>,
 
     // Status bar state
     server_status: ServerStatus,
@@ -111,7 +121,7 @@ impl BeepZoneApp {
         let login_screen = LoginScreen::new(&session_manager_blocking);
 
         // Try to restore session on startup
-        let (api_client, current_view, current_user) =
+        let (api_client, current_view, current_user, current_permissions) =
             if let Some(session) = session_manager_blocking.get_session() {
                 log::info!("Found saved session, attempting to restore...");
 
@@ -127,27 +137,37 @@ impl BeepZoneApp {
                                     "Session restored successfully for user: {}",
                                     session.user.username
                                 );
-                                (Some(client), AppView::Dashboard, Some(session.user.clone()))
+                                (
+                                    Some(client),
+                                    AppView::Dashboard,
+                                    Some(session.user.clone()),
+                                    session.permissions.clone(),
+                                )
                             }
                             Ok(false) => {
                                 log::warn!("Saved session check returned invalid");
-                                (None, AppView::Login, None)
+                                (None, AppView::Login, None, None)
                             }
                             Err(e) => {
                                 log::warn!("Saved session validity check error: {}", e);
                                 // Be forgiving on startup: keep client and let periodic checks refine
-                                (Some(client), AppView::Dashboard, Some(session.user.clone()))
+                                (
+                                    Some(client),
+                                    AppView::Dashboard,
+                                    Some(session.user.clone()),
+                                    session.permissions.clone(),
+                                )
                             }
                         }
                     }
                     Err(e) => {
                         log::error!("Failed to create API client: {}", e);
-                        (None, AppView::Login, None)
+                        (None, AppView::Login, None, None)
                     }
                 }
             } else {
                 log::info!("No saved session found");
-                (None, AppView::Login, None)
+                (None, AppView::Login, None, None)
             };
 
         drop(session_manager_blocking);
@@ -163,6 +183,7 @@ impl BeepZoneApp {
             previous_view: None,
             view_filter_states: HashMap::new(),
             current_user,
+            current_permissions,
             login_screen,
             dashboard: DashboardView::new(),
             inventory: InventoryView::new(),
@@ -179,6 +200,13 @@ impl BeepZoneApp {
             app_config,
             login_success: None,
             show_about: false,
+            should_exit_to_kiosk: false,
+            is_kiosk_mode: false,
+            enable_full_osk_button: false,
+            show_osk: false,
+            osk_shift_mode: false,
+            last_focused_id: None,
+            osk_event_queue: Vec::new(),
             server_status: ServerStatus::Unknown,
             last_health_check: std::time::Instant::now(),
             health_check_in_progress: false,
@@ -198,9 +226,26 @@ impl BeepZoneApp {
         app
     }
 
-    fn handle_login_success(&mut self, server_url: String, response: LoginResponse) {
+    pub fn handle_login_success(&mut self, server_url: String, response: LoginResponse) {
+        // Ensure we have token and user
+        let token = match response.token {
+            Some(t) => t,
+            None => {
+                log::error!("Login successful but no token returned");
+                return;
+            }
+        };
+        
+        let user = match response.user {
+            Some(u) => u,
+            None => {
+                log::error!("Login successful but no user returned");
+                return;
+            }
+        };
+
         // Capture username for logging before moving fields out of response
-        let username = response.user.username.clone();
+        let username = user.username.clone();
         log::info!("Login successful for user: {}", username);
 
         // Create API client with token
@@ -212,19 +257,39 @@ impl BeepZoneApp {
                 return;
             }
         };
-        api_client.set_token(response.token.clone());
+        api_client.set_token(token.clone());
 
-        self.api_client = Some(api_client);
-        self.current_user = Some(response.user.clone());
+        self.api_client = Some(api_client.clone());
+        self.current_user = Some(user.clone());
+
+        // Fetch permissions
+        let permissions = match api_client.get_permissions() {
+            Ok(resp) => {
+                if resp.success {
+                    log::info!("Permissions fetched successfully: {:?}", resp.permissions);
+                    Some(resp.permissions)
+                } else {
+                    log::warn!("Failed to fetch permissions: {:?}", resp.error);
+                    None
+                }
+            }
+            Err(e) => {
+                log::warn!("Error fetching permissions: {}", e);
+                None
+            }
+        };
+        
+        self.current_permissions = permissions.clone();
 
         // Save session (blocking is fine here, it's just writing a small JSON file)
         let session_data = SessionData {
             server_url,
-            token: response.token,
-            user: response.user,
+            token,
+            user,
             remember_server: true,
             remember_username: true,
             saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
+            permissions,
             default_printer_id: None,
             last_printer_id: None,
         };
@@ -246,6 +311,23 @@ impl BeepZoneApp {
     }
 
     fn handle_reauth_success(&mut self, server_url: String, response: LoginResponse) {
+        // Ensure we have token and user
+        let token = match response.token {
+            Some(t) => t,
+            None => {
+                log::error!("Reauth successful but no token returned");
+                return;
+            }
+        };
+        
+        let user = match response.user {
+            Some(u) => u,
+            None => {
+                log::error!("Reauth successful but no user returned");
+                return;
+            }
+        };
+
         // Preserve current view but refresh token and user
         let mut new_client = match ApiClient::new(server_url.clone()) {
             Ok(client) => client,
@@ -255,20 +337,39 @@ impl BeepZoneApp {
                 return;
             }
         };
-        new_client.set_token(response.token.clone());
+        new_client.set_token(token.clone());
 
         // Replace client and user
-        self.api_client = Some(new_client);
-        self.current_user = Some(response.user.clone());
+        self.api_client = Some(new_client.clone());
+        self.current_user = Some(user.clone());
+
+        // Fetch permissions
+        let permissions = match new_client.get_permissions() {
+            Ok(resp) => {
+                if resp.success {
+                    Some(resp.permissions)
+                } else {
+                    log::warn!("Failed to fetch permissions: {:?}", resp.error);
+                    None
+                }
+            }
+            Err(e) => {
+                log::warn!("Error fetching permissions: {}", e);
+                None
+            }
+        };
+        
+        self.current_permissions = permissions.clone();
 
         // Save updated session
         let session_data = SessionData {
             server_url,
-            token: response.token,
-            user: response.user,
+            token,
+            user,
             remember_server: true,
             remember_username: true,
             saved_username: self.current_user.as_ref().map(|u| u.username.clone()),
+            permissions,
             default_printer_id: None,
             last_printer_id: None,
         };
@@ -308,6 +409,13 @@ impl BeepZoneApp {
                                 self.show_about = true;
                             }
 
+                            if self.is_kiosk_mode && self.enable_full_osk_button {
+                                let btn_text = if self.show_osk { "Hide Keyboard" } else { "Keyboard" };
+                                if ui.button(btn_text).clicked() {
+                                    self.show_osk = !self.show_osk;
+                                }
+                            }
+
                             if ui.button("Bye").clicked() {
                                 self.handle_logout();
                             }
@@ -368,7 +476,7 @@ impl BeepZoneApp {
                                 ui.set_width(content_width);
                                 ui.scope(|ui| {
                                     ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
-                                    action_triggered = ribbon_ui.show(ctx, ui);
+                                    action_triggered = ribbon_ui.show(ctx, ui, self.current_permissions.as_ref());
                                 });
 
                                 // Update current view based on active ribbon tab
@@ -449,7 +557,7 @@ impl BeepZoneApp {
         action_triggered
     }
 
-    fn handle_logout(&mut self) {
+    pub fn handle_logout(&mut self) {
         log::info!("Taking myself out");
 
         // Logout from API
@@ -472,7 +580,13 @@ impl BeepZoneApp {
         self.api_client = None;
         self.current_user = None;
         self.current_view = AppView::Login;
+        self.should_exit_to_kiosk = true;
         self.server_status = ServerStatus::Unknown;
+
+        // Reset ribbon state to default
+        if let Some(ribbon) = &mut self.ribbon_ui {
+            ribbon.active_tab = "Dashboard".to_string();
+        }
     }
 
     /// Force an immediate health check (used when timeout errors detected)
@@ -492,7 +606,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 || {
@@ -880,6 +999,20 @@ impl BeepZoneApp {
 
 impl eframe::App for BeepZoneApp {
     fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+        // Inject queued OSK events at the start of the frame
+        if !self.osk_event_queue.is_empty() {
+            let events = std::mem::take(&mut self.osk_event_queue);
+            ctx.input_mut(|i| i.events.extend(events));
+        }
+
+        // Track focus for OSK
+        if let Some(id) = ctx.memory(|m| m.focused()) {
+            self.last_focused_id = Some(id);
+        }
+
+        // Show OSK first so it reserves space at the bottom (preventing click-through and overlap)
+        self.show_osk_overlay(ctx);
+
         // Detect view changes and save/restore filter state
         if let Some(prev_view) = self.previous_view {
             if prev_view != self.current_view {
@@ -971,7 +1104,7 @@ impl eframe::App for BeepZoneApp {
             }
 
             self.show_top_bar(ctx, block_interaction);
-            let ribbon_action = if block_interaction {
+            let ribbon_action = if block_interaction || self.current_view == AppView::Login {
                 None
             } else {
                 self.show_ribbon(ctx)
@@ -1009,11 +1142,12 @@ impl eframe::App for BeepZoneApp {
                         self.api_client.as_ref(),
                         self.ribbon_ui.as_mut(),
                         &self.session_manager,
+                        self.current_permissions.as_ref(),
                     );
                 }
                 AppView::Categories => {
                     self.categories
-                        .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut());
+                        .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut(), self.current_permissions.as_ref());
                 }
                 AppView::Zones => {
                     if let Some(ribbon) = self.ribbon_ui.as_mut() {
@@ -1025,7 +1159,7 @@ impl eframe::App for BeepZoneApp {
                                 .insert("zones_filter_changed".to_string(), true);
                         }
 
-                        self.zones.show(ui, self.api_client.as_ref(), ribbon);
+                        self.zones.show(ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
 
                         // Handle zone navigation request to inventory
                         if let Some(zone_code) = self.zones.switch_to_inventory_with_zone.take() {
@@ -1072,7 +1206,7 @@ impl eframe::App for BeepZoneApp {
                         }
 
                         self.borrowing
-                            .show(ctx, ui, self.api_client.as_ref(), ribbon);
+                            .show(ctx, ui, self.api_client.as_ref(), ribbon, self.current_permissions.as_ref());
 
                         // Handle borrower navigation request to inventory
                         if let Some(borrower_id) =
@@ -1115,7 +1249,7 @@ impl eframe::App for BeepZoneApp {
                 }
                 AppView::Audits => {
                     let user_id = self.current_user.as_ref().map(|u| u.id);
-                    self.audits.show(ctx, ui, self.api_client.as_ref(), user_id);
+                    self.audits.show(ctx, ui, self.api_client.as_ref(), user_id, self.current_permissions.as_ref());
                 }
                 AppView::Templates => {
                     if let Some(ribbon) = self.ribbon_ui.as_mut() {
@@ -1129,12 +1263,12 @@ impl eframe::App for BeepZoneApp {
 
                         let flags = self
                             .templates
-                            .show(ui, self.api_client.as_ref(), Some(ribbon));
+                            .show(ui, self.api_client.as_ref(), Some(ribbon), self.current_permissions.as_ref());
                         for flag in flags {
                             ribbon.checkboxes.insert(flag, false);
                         }
                     } else {
-                        self.templates.show(ui, self.api_client.as_ref(), None);
+                        self.templates.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
                     }
                 }
                 AppView::Suppliers => {
@@ -1143,16 +1277,17 @@ impl eframe::App for BeepZoneApp {
                             ui,
                             self.api_client.as_ref(),
                             Some(&mut *ribbon_ui),
+                            self.current_permissions.as_ref(),
                         );
                         for flag in flags {
                             ribbon_ui.checkboxes.insert(flag, false);
                         }
                     } else {
-                        let _ = self.suppliers.show(ui, self.api_client.as_ref(), None);
+                        let _ = self.suppliers.show(ui, self.api_client.as_ref(), None, self.current_permissions.as_ref());
                     }
                 }
                 AppView::IssueTracker => {
-                    self.issues.show(ui, self.api_client.as_ref());
+                    self.issues.show(ui, self.api_client.as_ref(), self.current_permissions.as_ref());
                 }
                 AppView::Printers => {
                     // Render printers dropdown in ribbon if we're on printers tab
@@ -1167,6 +1302,7 @@ impl eframe::App for BeepZoneApp {
                         self.api_client.as_ref(),
                         self.ribbon_ui.as_mut(),
                         &self.session_manager,
+                        self.current_permissions.as_ref(),
                     );
                 }
                 AppView::LabelTemplates => {
@@ -1174,9 +1310,12 @@ impl eframe::App for BeepZoneApp {
                         ui,
                         self.api_client.as_ref(),
                         self.ribbon_ui.as_mut(),
+                        self.current_permissions.as_ref(),
                     );
                 }
-                AppView::Login => unreachable!(),
+                AppView::Login => {
+                    // Do nothing, we are transitioning to logout
+                }
             });
             } else {
                 self.show_reconnect_overlay(ctx);
@@ -1266,3 +1405,108 @@ impl eframe::App for BeepZoneApp {
         }
     }
 }
+
+impl BeepZoneApp {
+    fn show_osk_overlay(&mut self, ctx: &egui::Context) {
+        if !self.show_osk { return; }
+
+        let height = 340.0;
+        egui::TopBottomPanel::bottom("osk_panel")
+            .resizable(false)
+            .min_height(height)
+            .show(ctx, |ui| {
+                ui.vertical_centered(|ui| {
+                    ui.add_space(10.0);
+                    
+                    // Styling
+                    let btn_size = egui::vec2(50.0, 50.0);
+                    let spacing = 6.0;
+                    ui.style_mut().spacing.item_spacing = egui::vec2(spacing, spacing);
+
+                    // Layouts
+                    let rows_lower = [
+                        vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "="],
+                        vec!["q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "[", "]"],
+                        vec!["a", "s", "d", "f", "g", "h", "j", "k", "l", ";", "'", "\\"],
+                        vec!["z", "x", "c", "v", "b", "n", "m", ",", ".", "/"],
+                    ];
+
+                    let rows_upper = [
+                        vec!["!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+"],
+                        vec!["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{", "}"],
+                        vec!["A", "S", "D", "F", "G", "H", "J", "K", "L", ":", "\"", "|"],
+                        vec!["Z", "X", "C", "V", "B", "N", "M", "<", ">", "?"],
+                    ];
+
+                    let rows = if self.osk_shift_mode { rows_upper } else { rows_lower };
+
+                    for row in rows {
+                        ui.horizontal(|ui| {
+                            // Center row
+                            let width = row.len() as f32 * (btn_size.x + spacing) - spacing;
+                            let margin = (ui.available_width() - width) / 2.0;
+                            if margin > 0.0 { ui.add_space(margin); }
+
+                            for key in row {
+                                if ui.add_sized(btn_size, egui::Button::new(egui::RichText::new(key).size(24.0))).clicked() {
+                                    // Queue text event for next frame
+                                    self.osk_event_queue.push(egui::Event::Text(key.to_string()));
+                                    
+                                    // Restore focus immediately
+                                    if let Some(id) = self.last_focused_id {
+                                        ctx.memory_mut(|m| m.request_focus(id));
+                                    }
+                                }
+                            }
+                        });
+                    }
+                    
+                    // Modifiers and Actions
+                    ui.horizontal(|ui| {
+                        let shift_width = 100.0;
+                        let space_width = 300.0;
+                        let back_width = 100.0;
+                        let total_width = shift_width + space_width + back_width + (spacing * 2.0);
+                        let margin = (ui.available_width() - total_width) / 2.0;
+                        if margin > 0.0 { ui.add_space(margin); }
+                        
+                        // Shift
+                        let shift_text = if self.osk_shift_mode { "SHIFT (ON)" } else { "SHIFT" };
+                        let shift_btn = egui::Button::new(egui::RichText::new(shift_text).size(20.0))
+                            .fill(if self.osk_shift_mode { egui::Color32::from_rgb(100, 100, 255) } else { ui.visuals().widgets.inactive.bg_fill });
+                        
+                        if ui.add_sized(egui::vec2(shift_width, 50.0), shift_btn).clicked() {
+                            self.osk_shift_mode = !self.osk_shift_mode;
+                            if let Some(id) = self.last_focused_id {
+                                ctx.memory_mut(|m| m.request_focus(id));
+                            }
+                        }
+
+                        // Space
+                        if ui.add_sized(egui::vec2(space_width, 50.0), egui::Button::new(egui::RichText::new("SPACE").size(20.0))).clicked() {
+                             self.osk_event_queue.push(egui::Event::Text(" ".to_string()));
+                             if let Some(id) = self.last_focused_id {
+                                ctx.memory_mut(|m| m.request_focus(id));
+                            }
+                        }
+
+                        // Backspace
+                        if ui.add_sized(egui::vec2(back_width, 50.0), egui::Button::new(egui::RichText::new("BACK").size(20.0))).clicked() {
+                             self.osk_event_queue.push(egui::Event::Key {
+                                 key: egui::Key::Backspace,
+                                 pressed: true,
+                                 modifiers: egui::Modifiers::NONE,
+                                 repeat: false,
+                                 physical_key: None,
+                             });
+                             if let Some(id) = self.last_focused_id {
+                                ctx.memory_mut(|m| m.request_focus(id));
+                            }
+                        }
+                    });
+                    ui.add_space(10.0);
+                });
+            });
+    }
+}
+

+ 125 - 8
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) => {
@@ -124,6 +143,7 @@ impl AuditsView {
         ui: &mut egui::Ui,
         api: &ApiClient,
         current_user_id: Option<i32>,
+        permissions: Option<&serde_json::Value>,
     ) {
         egui::Frame::group(ui.style())
             .fill(ui.style().visuals.extreme_bg_color)
@@ -140,10 +160,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,12 +190,97 @@ impl AuditsView {
                             .desired_width(input_w),
                     );
 
-                    let disable_new = self.workflow.is_active();
+                    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 has_perm = crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_audit");
+                    let disable_new = self.workflow.is_active() || !has_perm;
+                    
                     let start_zone_clicked_button = ui
                         .add_enabled(
                             !disable_new,
                             egui::Button::new("Start Zone Audit").min_size(egui::vec2(btn_w, 0.0)),
                         )
+                        .on_disabled_hover_text(if !has_perm { "Permission denied" } else { "Audit in progress" })
                         .clicked();
 
                     let start_spot_clicked = ui
@@ -182,13 +288,16 @@ impl AuditsView {
                             !disable_new,
                             egui::Button::new("Start Spot Check").min_size(egui::vec2(btn_w, 0.0)),
                         )
+                        .on_disabled_hover_text(if !has_perm { "Permission denied" } else { "Audit in progress" })
                         .clicked();
 
                     let start_zone_pressed_enter =
-                        text_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
-                    let start_zone_clicked = start_zone_clicked_button || start_zone_pressed_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) && has_perm;
 
                     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() {
@@ -296,7 +405,15 @@ impl AuditsView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         current_user_id: Option<i32>,
+        permissions: Option<&serde_json::Value>,
     ) {
+        // 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| {
@@ -329,7 +446,7 @@ impl AuditsView {
                 ui.separator();
 
                 if let Some(api) = api_client {
-                    self.render_launch_controls(ui, api, current_user_id);
+                    self.render_launch_controls(ui, api, current_user_id, permissions);
                 }
 
                 if !self.init_loaded {

+ 11 - 4
src/ui/borrowing.rs

@@ -265,6 +265,7 @@ impl BorrowingView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon: &mut crate::ui::ribbon::RibbonUI,
+        permissions: Option<&serde_json::Value>,
     ) {
         ui.horizontal(|ui| {
             ui.heading("Borrowing");
@@ -309,7 +310,9 @@ impl BorrowingView {
                 .copied()
                 .unwrap_or(false)
             {
-                self.borrow_flow.open(api);
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "checkout_asset") {
+                    self.borrow_flow.open(api);
+                }
             }
 
             if ribbon
@@ -318,7 +321,9 @@ impl BorrowingView {
                 .copied()
                 .unwrap_or(false)
             {
-                self.return_flow.open(api);
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "return_asset") {
+                    self.return_flow.open(api);
+                }
             }
 
             if ribbon
@@ -327,8 +332,10 @@ impl BorrowingView {
                 .copied()
                 .unwrap_or(false)
             {
-                self.show_register_dialog = true;
-                self.register_error = None;
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_borrower") {
+                    self.show_register_dialog = true;
+                    self.register_error = None;
+                }
             }
 
             if ribbon

+ 74 - 61
src/ui/categories.rs

@@ -408,6 +408,7 @@ impl CategoriesView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon: Option<&mut RibbonUI>,
+        permissions: Option<&serde_json::Value>,
     ) -> Vec<String> {
         let mut flags_to_clear = Vec::new();
 
@@ -415,29 +416,35 @@ impl CategoriesView {
         if let Some(item) = ui.ctx().data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("cat_double_click_edit"))
         }) {
-            self.open_editor_with(&item);
-            ui.ctx().request_repaint();
+            if RibbonUI::check_permission(permissions, "edit_category") {
+                self.open_editor_with(&item);
+                ui.ctx().request_repaint();
+            }
         }
 
         if let Some(item) = ui.ctx().data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("cat_context_menu_edit"))
         }) {
-            self.open_editor_with(&item);
-            ui.ctx().request_repaint();
+            if RibbonUI::check_permission(permissions, "edit_category") {
+                self.open_editor_with(&item);
+                ui.ctx().request_repaint();
+            }
         }
 
         if let Some(item) = ui.ctx().data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("cat_context_menu_delete"))
         }) {
-            let name = item
-                .get("category_name")
-                .and_then(|v| v.as_str())
-                .unwrap_or("Unknown")
-                .to_string();
-            let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
-            self.pending_delete_ids = vec![id]; // Changed to vector
-            self.delete_dialog.open(name, id.to_string());
-            ui.ctx().request_repaint();
+            if RibbonUI::check_permission(permissions, "delete_category") {
+                let name = item
+                    .get("category_name")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("Unknown")
+                    .to_string();
+                let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
+                self.pending_delete_ids = vec![id]; // Changed to vector
+                self.delete_dialog.open(name, id.to_string());
+                ui.ctx().request_repaint();
+            }
         }
 
         // Auto-load on first show, but only try once unless user explicitly requests retry
@@ -477,9 +484,11 @@ impl CategoriesView {
                 .copied()
                 .unwrap_or(false)
             {
-                // Create a new add dialog with current category options
-                self.add_dialog = self.create_add_dialog_with_options();
-                self.add_dialog.open(&serde_json::json!({})); // Open with empty data
+                if RibbonUI::check_permission(permissions, "create_category") {
+                    // Create a new add dialog with current category options
+                    self.add_dialog = self.create_add_dialog_with_options();
+                    self.add_dialog.open(&serde_json::json!({})); // Open with empty data
+                }
                 flags_to_clear.push("categories_add".to_string());
             }
 
@@ -489,25 +498,27 @@ impl CategoriesView {
                 .copied()
                 .unwrap_or(false)
             {
-                // Get selected category IDs
-                let selected_ids = self.get_selected_ids();
-
-                if !selected_ids.is_empty() {
-                    // For edit, only edit the first selected category (bulk edit of categories is complex)
-                    if let Some(&first_id) = selected_ids.first() {
-                        // Clone the category to avoid borrowing issues
-                        let category = self
-                            .categories
-                            .iter()
-                            .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(first_id))
-                            .cloned();
-
-                        if let Some(cat) = category {
-                            self.open_editor_with(&cat);
+                if RibbonUI::check_permission(permissions, "edit_category") {
+                    // Get selected category IDs
+                    let selected_ids = self.get_selected_ids();
+
+                    if !selected_ids.is_empty() {
+                        // For edit, only edit the first selected category (bulk edit of categories is complex)
+                        if let Some(&first_id) = selected_ids.first() {
+                            // Clone the category to avoid borrowing issues
+                            let category = self
+                                .categories
+                                .iter()
+                                .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(first_id))
+                                .cloned();
+
+                            if let Some(cat) = category {
+                                self.open_editor_with(&cat);
+                            }
                         }
+                    } else {
+                        log::warn!("Edit requested but no categories selected");
                     }
-                } else {
-                    log::warn!("Edit requested but no categories selected");
                 }
                 flags_to_clear.push("categories_edit".to_string());
             }
@@ -518,36 +529,38 @@ impl CategoriesView {
                 .copied()
                 .unwrap_or(false)
             {
-                // Get selected category IDs for bulk delete
-                let selected_ids = self.get_selected_ids();
-
-                if !selected_ids.is_empty() {
-                    self.pending_delete_ids = selected_ids.clone();
-                    let count = selected_ids.len();
-
-                    // Show dialog with appropriate message for single or multiple deletes
-                    let message =
-                        if count == 1 {
-                            // Get the category name for single delete
-                            if let Some(category) = self.categories.iter().find(|c| {
-                                c.get("id").and_then(|v| v.as_i64()) == Some(selected_ids[0])
-                            }) {
-                                category
-                                    .get("category_name")
-                                    .and_then(|v| v.as_str())
-                                    .unwrap_or("Unknown")
-                                    .to_string()
+                if RibbonUI::check_permission(permissions, "delete_category") {
+                    // Get selected category IDs for bulk delete
+                    let selected_ids = self.get_selected_ids();
+
+                    if !selected_ids.is_empty() {
+                        self.pending_delete_ids = selected_ids.clone();
+                        let count = selected_ids.len();
+
+                        // Show dialog with appropriate message for single or multiple deletes
+                        let message =
+                            if count == 1 {
+                                // Get the category name for single delete
+                                if let Some(category) = self.categories.iter().find(|c| {
+                                    c.get("id").and_then(|v| v.as_i64()) == Some(selected_ids[0])
+                                }) {
+                                    category
+                                        .get("category_name")
+                                        .and_then(|v| v.as_str())
+                                        .unwrap_or("Unknown")
+                                        .to_string()
+                                } else {
+                                    "Unknown".to_string()
+                                }
                             } else {
-                                "Unknown".to_string()
-                            }
-                        } else {
-                            format!("{} categories", count)
-                        };
+                                format!("{} categories", count)
+                            };
 
-                    self.delete_dialog
-                        .open(message, format!("IDs: {:?}", selected_ids));
-                } else {
-                    log::warn!("Delete requested but no categories selected");
+                        self.delete_dialog
+                            .open(message, format!("IDs: {:?}", selected_ids));
+                    } else {
+                        log::warn!("Delete requested but no categories selected");
+                    }
                 }
                 flags_to_clear.push("categories_delete".to_string());
             }

+ 256 - 126
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,
         }
     }
 
@@ -451,6 +468,7 @@ impl InventoryView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+        permissions: Option<&serde_json::Value>,
     ) {
         // We need to work around Rust's borrowing rules here
         // First, get the data we need
@@ -461,6 +479,7 @@ impl InventoryView {
         let mut deferred_actions: Vec<DeferredAction> = Vec::new();
         let mut temp_handler = TempInventoryEventHandler {
             deferred_actions: &mut deferred_actions,
+            permissions,
         };
 
         // Render table with the temporary event handler
@@ -481,10 +500,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 +565,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 +636,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);
+                    }
+                }
             }
         }
     }
@@ -746,6 +785,7 @@ impl InventoryView {
         api_client: Option<&ApiClient>,
         ribbon_ui: Option<&mut crate::ui::ribbon::RibbonUI>,
         session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+        permissions: Option<&serde_json::Value>,
     ) {
         // Handle initial load if needed - but ONLY if we've never loaded before
         // Don't auto-load if assets are empty due to filters returning 0 results
@@ -784,6 +824,7 @@ impl InventoryView {
             Some(100),
             ribbon_ui.as_ref().map(|r| &**r),
             session_manager,
+            permissions,
         );
 
         // Clear the flags after processing
@@ -801,10 +842,11 @@ impl InventoryView {
         limit: Option<u32>,
         ribbon_ui: Option<&crate::ui::ribbon::RibbonUI>,
         session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+        permissions: Option<&serde_json::Value>,
     ) -> Vec<String> {
         // Handle ribbon actions first
         let flags_to_clear = if let Some(ribbon) = ribbon_ui {
-            self.handle_ribbon_actions(ribbon, api_client, session_manager)
+            self.handle_ribbon_actions(ribbon, api_client, session_manager, permissions)
         } else {
             Vec::new()
         };
@@ -842,8 +884,10 @@ impl InventoryView {
                     }
                 }
 
-                if ui.button("➕ Add Asset").clicked() {
-                    self.prepare_add_asset_editor(client);
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_asset") {
+                    if ui.button("➕ Add Asset").clicked() {
+                        self.prepare_add_asset_editor(client);
+                    }
                 }
 
                 // Show selection count but no buttons (use right-click instead)
@@ -996,6 +1040,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"
@@ -1046,7 +1093,7 @@ impl InventoryView {
         }
 
         // Render table with event handling
-        self.render_table_with_events(ui, api_client, session_manager);
+        self.render_table_with_events(ui, api_client, session_manager, permissions);
 
         // Handle dialogs
         self.handle_dialogs(ui, api_client, limit, session_manager);
@@ -1124,6 +1171,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 +1350,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 +1449,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")))
@@ -1427,6 +1504,7 @@ impl InventoryView {
         ribbon: &crate::ui::ribbon::RibbonUI,
         api_client: Option<&ApiClient>,
         session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+        permissions: Option<&serde_json::Value>,
     ) -> Vec<String> {
         let mut flags_to_clear = Vec::new();
 
@@ -1504,20 +1582,22 @@ impl InventoryView {
             .get("inventory_action_add")
             .unwrap_or(&false)
         {
-            if let Some(client) = api_client {
-                self.prepare_add_asset_editor(client);
-            } else {
-                let mut preset = serde_json::Map::new();
-                preset.insert(
-                    "asset_type".to_string(),
-                    serde_json::Value::String("N".to_string()),
-                );
-                preset.insert(
-                    "status".to_string(),
-                    serde_json::Value::String("Good".to_string()),
-                );
-                self.add_dialog.title = "Add Asset".to_string();
-                self.add_dialog.open_new(Some(&preset));
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_asset") {
+                if let Some(client) = api_client {
+                    self.prepare_add_asset_editor(client);
+                } else {
+                    let mut preset = serde_json::Map::new();
+                    preset.insert(
+                        "asset_type".to_string(),
+                        serde_json::Value::String("N".to_string()),
+                    );
+                    preset.insert(
+                        "status".to_string(),
+                        serde_json::Value::String("Good".to_string()),
+                    );
+                    self.add_dialog.title = "Add Asset".to_string();
+                    self.add_dialog.open_new(Some(&preset));
+                }
             }
         }
 
@@ -1526,27 +1606,29 @@ impl InventoryView {
             .get("inventory_action_delete")
             .unwrap_or(&false)
         {
-            if !selected_ids.is_empty() {
-                self.pending_delete_ids = selected_ids.clone();
-                if selected_ids.len() == 1 {
-                    if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
-                        let name = asset
-                            .get("name")
-                            .and_then(|v| v.as_str())
-                            .unwrap_or("Unknown")
-                            .to_string();
-                        self.delete_dialog.open(name, selected_ids[0].to_string());
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_asset") {
+                if !selected_ids.is_empty() {
+                    self.pending_delete_ids = selected_ids.clone();
+                    if selected_ids.len() == 1 {
+                        if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
+                            let name = asset
+                                .get("name")
+                                .and_then(|v| v.as_str())
+                                .unwrap_or("Unknown")
+                                .to_string();
+                            self.delete_dialog.open(name, selected_ids[0].to_string());
+                        }
+                    } else {
+                        self.delete_dialog.title = "Delete Assets".to_string();
+                        self.delete_dialog.message = format!(
+                            "Are you sure you want to delete {} selected assets?",
+                            selected_ids.len()
+                        );
+                        self.delete_dialog.open(
+                            format!("Multiple items ({} selected)", selected_ids.len()),
+                            "multiple".to_string(),
+                        );
                     }
-                } else {
-                    self.delete_dialog.title = "Delete Assets".to_string();
-                    self.delete_dialog.message = format!(
-                        "Are you sure you want to delete {} selected assets?",
-                        selected_ids.len()
-                    );
-                    self.delete_dialog.open(
-                        format!("Multiple items ({} selected)", selected_ids.len()),
-                        "multiple".to_string(),
-                    );
                 }
             }
         }
@@ -1556,18 +1638,20 @@ impl InventoryView {
             .get("inventory_action_edit_easy")
             .unwrap_or(&false)
         {
-            if !selected_ids.is_empty() {
-                self.pending_edit_ids = selected_ids.clone();
-                self.is_bulk_edit = selected_ids.len() > 1;
-                if let Some(client) = api_client {
-                    self.prepare_easy_edit_fields(client);
-                }
-                if selected_ids.len() == 1 {
-                    if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
-                        self.edit_dialog.open(&asset);
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_asset") {
+                if !selected_ids.is_empty() {
+                    self.pending_edit_ids = selected_ids.clone();
+                    self.is_bulk_edit = selected_ids.len() > 1;
+                    if let Some(client) = api_client {
+                        self.prepare_easy_edit_fields(client);
+                    }
+                    if selected_ids.len() == 1 {
+                        if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
+                            self.edit_dialog.open(&asset);
+                        }
+                    } else {
+                        self.edit_dialog.open_new(None);
                     }
-                } else {
-                    self.edit_dialog.open_new(None);
                 }
             }
         }
@@ -1577,18 +1661,20 @@ impl InventoryView {
             .get("inventory_action_edit_adv")
             .unwrap_or(&false)
         {
-            if !selected_ids.is_empty() {
-                self.pending_edit_ids = selected_ids.clone();
-                self.is_bulk_edit = selected_ids.len() > 1;
-                if let Some(client) = api_client {
-                    self.prepare_advanced_edit_fields(client);
-                }
-                if selected_ids.len() == 1 {
-                    if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
-                        self.advanced_edit_dialog.open(&asset);
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_asset") {
+                if !selected_ids.is_empty() {
+                    self.pending_edit_ids = selected_ids.clone();
+                    self.is_bulk_edit = selected_ids.len() > 1;
+                    if let Some(client) = api_client {
+                        self.prepare_advanced_edit_fields(client);
+                    }
+                    if selected_ids.len() == 1 {
+                        if let Some(asset) = self.find_asset_by_id(selected_ids[0]) {
+                            self.advanced_edit_dialog.open(&asset);
+                        }
+                    } else {
+                        self.advanced_edit_dialog.open_new(None);
                     }
-                } else {
-                    self.advanced_edit_dialog.open_new(None);
                 }
             }
         }
@@ -1771,58 +1857,96 @@ enum DeferredAction {
     ContextReturn(Value),
     ContextPrintLabel(Value),
     ContextAdvancedPrint(Value),
+    ContextReplace(Value),
     ContextClone(Value),
+    ContextHistory(Value),
 }
 
 // Temporary event handler that collects actions for later processing
 struct TempInventoryEventHandler<'a> {
     deferred_actions: &'a mut Vec<DeferredAction>,
+    permissions: Option<&'a serde_json::Value>,
 }
 
 impl<'a> TableEventHandler<Value> for TempInventoryEventHandler<'a> {
     fn on_double_click(&mut self, item: &Value, _row_index: usize) {
-        log::info!("Double-click detected on asset: {:?}", item.get("name"));
-        self.deferred_actions
-            .push(DeferredAction::DoubleClick(item.clone()));
+        // Check edit permission for double-click action
+        if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "edit_asset") {
+            log::info!("Double-click detected on asset: {:?}", item.get("name"));
+            self.deferred_actions
+                .push(DeferredAction::DoubleClick(item.clone()));
+        }
     }
 
     fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
-        if ui
-            .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
-            .clicked()
-        {
-            log::info!(
-                "Context menu edit clicked for asset: {:?}",
-                item.get("name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextEdit(item.clone()));
-            ui.close();
+        if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "edit_asset") {
+            if ui
+                .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu edit clicked for asset: {:?}",
+                    item.get("name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextEdit(item.clone()));
+                ui.close();
+            }
         }
 
-        if ui
-            .button(format!("{} Clone Asset", egui_phosphor::regular::COPY))
-            .clicked()
-        {
-            log::info!(
-                "Context menu clone clicked for asset: {:?}",
-                item.get("name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextClone(item.clone()));
-            ui.close();
+        if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "create_asset") {
+            if ui
+                .button(format!("{} Clone Asset", egui_phosphor::regular::COPY))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu clone clicked for asset: {:?}",
+                    item.get("name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextClone(item.clone()));
+                ui.close();
+            }
+        }
+
+        if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "edit_asset") {
+            if ui
+                .button(format!("{} Advanced Edit", egui_phosphor::regular::GEAR))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu advanced edit clicked for asset: {:?}",
+                    item.get("name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextAdvancedEdit(item.clone()));
+                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!("{} Advanced Edit", egui_phosphor::regular::GEAR))
+            .button(format!("{} View History", egui_phosphor::regular::CLOCK_COUNTER_CLOCKWISE))
             .clicked()
         {
             log::info!(
-                "Context menu advanced edit clicked for asset: {:?}",
+                "Context menu history clicked for asset: {:?}",
                 item.get("name")
             );
             self.deferred_actions
-                .push(DeferredAction::ContextAdvancedEdit(item.clone()));
+                .push(DeferredAction::ContextHistory(item.clone()));
             ui.close();
         }
 
@@ -1878,52 +2002,58 @@ impl<'a> TableEventHandler<Value> for TempInventoryEventHandler<'a> {
 
         if lendable && !status.is_empty() {
             if status == "Available" {
-                if ui
-                    .button(format!("{} Lend Item", egui_phosphor::regular::ARROW_LEFT))
-                    .clicked()
-                {
-                    log::info!(
-                        "Context menu lend clicked for asset: {:?}",
-                        item.get("name")
-                    );
-                    self.deferred_actions
-                        .push(DeferredAction::ContextLend(item.clone()));
-                    ui.close();
+                if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "checkout_asset") {
+                    if ui
+                        .button(format!("{} Lend Item", egui_phosphor::regular::ARROW_LEFT))
+                        .clicked()
+                    {
+                        log::info!(
+                            "Context menu lend clicked for asset: {:?}",
+                            item.get("name")
+                        );
+                        self.deferred_actions
+                            .push(DeferredAction::ContextLend(item.clone()));
+                        ui.close();
+                    }
                 }
             } else if matches!(
                 status,
                 "Borrowed" | "Overdue" | "Stolen" | "Illegally Handed Out" | "Deployed"
             ) {
-                if ui
-                    .button(format!(
-                        "{} Return Item",
-                        egui_phosphor::regular::ARROW_RIGHT
-                    ))
-                    .clicked()
-                {
-                    log::info!(
-                        "Context menu return clicked for asset: {:?}",
-                        item.get("name")
-                    );
-                    self.deferred_actions
-                        .push(DeferredAction::ContextReturn(item.clone()));
-                    ui.close();
+                if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "return_asset") {
+                    if ui
+                        .button(format!(
+                            "{} Return Item",
+                            egui_phosphor::regular::ARROW_RIGHT
+                        ))
+                        .clicked()
+                    {
+                        log::info!(
+                            "Context menu return clicked for asset: {:?}",
+                            item.get("name")
+                        );
+                        self.deferred_actions
+                            .push(DeferredAction::ContextReturn(item.clone()));
+                        ui.close();
+                    }
                 }
             }
             ui.separator();
         }
 
-        if ui
-            .button(format!("{} Delete", egui_phosphor::regular::TRASH))
-            .clicked()
-        {
-            log::info!(
-                "Context menu delete clicked for asset: {:?}",
-                item.get("name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextDelete(item.clone()));
-            ui.close();
+        if crate::ui::ribbon::RibbonUI::check_permission(self.permissions, "delete_asset") {
+            if ui
+                .button(format!("{} Delete", egui_phosphor::regular::TRASH))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu delete clicked for asset: {:?}",
+                    item.get("name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextDelete(item.clone()));
+                ui.close();
+            }
         }
     }
 

+ 59 - 39
src/ui/issues.rs

@@ -240,7 +240,12 @@ impl IssuesView {
         self.recompute_summary();
     }
 
-    pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+    pub fn show(
+        &mut self,
+        ui: &mut egui::Ui,
+        api_client: Option<&ApiClient>,
+        permissions: Option<&serde_json::Value>,
+    ) {
         ui.horizontal(|ui| {
             ui.heading("Issues");
             if self.is_loading {
@@ -287,7 +292,7 @@ impl IssuesView {
 
         let visible_columns: Vec<ColumnConfig> =
             self.columns.iter().filter(|c| c.visible).cloned().collect();
-        self.render_table(ui, &visible_columns);
+        self.render_table(ui, &visible_columns, permissions);
 
         // Process selection/dialog events
         let ctx = ui.ctx();
@@ -302,27 +307,33 @@ impl IssuesView {
         if let Some(item) = ctx.data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("iss_double_click_edit"))
         }) {
-            self.open_editor_with(&item);
-            ctx.request_repaint();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_issue") {
+                self.open_editor_with(&item);
+                ctx.request_repaint();
+            }
         }
         if let Some(item) = ctx.data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_edit"))
         }) {
-            self.open_editor_with(&item);
-            ctx.request_repaint();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_issue") {
+                self.open_editor_with(&item);
+                ctx.request_repaint();
+            }
         }
         if let Some(item) = ctx.data_mut(|d| {
             d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_delete"))
         }) {
-            let title = item
-                .get("title")
-                .and_then(|v| v.as_str())
-                .unwrap_or("Unknown")
-                .to_string();
-            let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
-            self.delete_current_id = Some(id);
-            self.delete_dialog.open(title, id.to_string());
-            ctx.request_repaint();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_issue") {
+                let title = item
+                    .get("title")
+                    .and_then(|v| v.as_str())
+                    .unwrap_or("Unknown")
+                    .to_string();
+                let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
+                self.delete_current_id = Some(id);
+                self.delete_dialog.open(title, id.to_string());
+                ctx.request_repaint();
+            }
         }
 
         if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) {
@@ -493,7 +504,12 @@ impl IssuesView {
             });
     }
 
-    fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) {
+    fn render_table(
+        &mut self,
+        ui: &mut egui::Ui,
+        visible_columns: &Vec<ColumnConfig>,
+        permissions: Option<&serde_json::Value>,
+    ) {
         use egui_extras::{Column, TableBuilder};
         let mut table = TableBuilder::new(ui)
             .striped(true)
@@ -638,30 +654,34 @@ impl IssuesView {
                             }
                         }
                         row_resp.context_menu(|ui| {
-                            if ui
-                                .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
-                                .clicked()
-                            {
-                                ui.ctx().data_mut(|d| {
-                                    d.insert_temp(
-                                        egui::Id::new("iss_context_menu_edit"),
-                                        r_clone.clone(),
-                                    )
-                                });
-                                ui.close();
+                            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_issue") {
+                                if ui
+                                    .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
+                                    .clicked()
+                                {
+                                    ui.ctx().data_mut(|d| {
+                                        d.insert_temp(
+                                            egui::Id::new("iss_context_menu_edit"),
+                                            r_clone.clone(),
+                                        )
+                                    });
+                                    ui.close();
+                                }
+                                ui.separator();
                             }
-                            ui.separator();
-                            if ui
-                                .button(format!("{} Delete", egui_phosphor::regular::TRASH))
-                                .clicked()
-                            {
-                                ui.ctx().data_mut(|d| {
-                                    d.insert_temp(
-                                        egui::Id::new("iss_context_menu_delete"),
-                                        r_clone.clone(),
-                                    )
-                                });
-                                ui.close();
+                            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_issue") {
+                                if ui
+                                    .button(format!("{} Delete", egui_phosphor::regular::TRASH))
+                                    .clicked()
+                                {
+                                    ui.ctx().data_mut(|d| {
+                                        d.insert_temp(
+                                            egui::Id::new("iss_context_menu_delete"),
+                                            r_clone.clone(),
+                                        )
+                                    });
+                                    ui.close();
+                                }
                             }
                         });
                     });

+ 66 - 48
src/ui/label_templates.rs

@@ -162,6 +162,7 @@ impl LabelTemplatesView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon_ui: Option<&mut RibbonUI>,
+        permissions: Option<&serde_json::Value>,
     ) {
         self.ensure_loaded(api_client);
 
@@ -183,8 +184,9 @@ impl LabelTemplatesView {
                 .copied()
                 .unwrap_or(false)
             {
-                // Provide helpful default layout JSON template matching database schema
-                let layout_json = r##"{
+                if RibbonUI::check_permission(permissions, "create_label_template") {
+                    // Provide helpful default layout JSON template matching database schema
+                    let layout_json = r##"{
   "version": "1.0",
   "background": "#FFFFFF",
   "elements": [
@@ -214,10 +216,11 @@ impl LabelTemplatesView {
     }
   ]
 }"##;
-                let default_data = serde_json::json!({
-                    "layout_json": layout_json
-                });
-                self.add_dialog.open(&default_data);
+                    let default_data = serde_json::json!({
+                        "layout_json": layout_json
+                    });
+                    self.add_dialog.open(&default_data);
+                }
             }
             if ribbon
                 .checkboxes
@@ -254,7 +257,7 @@ impl LabelTemplatesView {
         }
 
         // Render table with event handling
-        self.render_table_with_events(ui, api_client);
+        self.render_table_with_events(ui, api_client, permissions);
 
         // Handle dialogs
         self.handle_dialogs(ui, api_client);
@@ -263,7 +266,12 @@ impl LabelTemplatesView {
         self.process_deferred_actions(ui, api_client);
     }
 
-    fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+    fn render_table_with_events(
+        &mut self,
+        ui: &mut egui::Ui,
+        api_client: Option<&ApiClient>,
+        permissions: Option<&serde_json::Value>,
+    ) {
         let templates_clone = self.templates.clone();
         let prepared_data = self.table_renderer.prepare_json_data(&templates_clone);
 
@@ -271,6 +279,7 @@ impl LabelTemplatesView {
         let mut temp_handler = TempTemplatesEventHandler {
             api_client,
             deferred_actions: &mut deferred_actions,
+            permissions,
         };
 
         self.table_renderer
@@ -544,60 +553,69 @@ struct TempTemplatesEventHandler<'a> {
     #[allow(dead_code)]
     api_client: Option<&'a ApiClient>,
     deferred_actions: &'a mut Vec<DeferredAction>,
+    permissions: Option<&'a serde_json::Value>,
 }
 
 impl<'a> TableEventHandler<Value> for TempTemplatesEventHandler<'a> {
     fn on_double_click(&mut self, item: &Value, _row_index: usize) {
-        log::info!(
-            "Double-click detected on template: {:?}",
-            item.get("template_name")
-        );
-        self.deferred_actions
-            .push(DeferredAction::DoubleClick(item.clone()));
-    }
-
-    fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
-        if ui
-            .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL))
-            .clicked()
-        {
+        if RibbonUI::check_permission(self.permissions, "edit_label_template") {
             log::info!(
-                "Context menu edit clicked for template: {:?}",
+                "Double-click detected on template: {:?}",
                 item.get("template_name")
             );
             self.deferred_actions
-                .push(DeferredAction::ContextEdit(item.clone()));
-            ui.close();
+                .push(DeferredAction::DoubleClick(item.clone()));
         }
+    }
 
-        ui.separator();
+    fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
+        if RibbonUI::check_permission(self.permissions, "edit_label_template") {
+            if ui
+                .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu edit clicked for template: {:?}",
+                    item.get("template_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextEdit(item.clone()));
+                ui.close();
+            }
 
-        if ui
-            .button(format!("{} Clone Template", egui_phosphor::regular::COPY))
-            .clicked()
-        {
-            log::info!(
-                "Context menu clone clicked for template: {:?}",
-                item.get("template_name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextClone(item.clone()));
-            ui.close();
+            ui.separator();
         }
 
-        ui.separator();
+        if RibbonUI::check_permission(self.permissions, "create_label_template") {
+            if ui
+                .button(format!("{} Clone Template", egui_phosphor::regular::COPY))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu clone clicked for template: {:?}",
+                    item.get("template_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextClone(item.clone()));
+                ui.close();
+            }
 
-        if ui
-            .button(format!("{} Delete Template", egui_phosphor::regular::TRASH))
-            .clicked()
-        {
-            log::info!(
-                "Context menu delete clicked for template: {:?}",
-                item.get("template_name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextDelete(item.clone()));
-            ui.close();
+            ui.separator();
+        }
+
+        if RibbonUI::check_permission(self.permissions, "delete_label_template") {
+            if ui
+                .button(format!("{} Delete Template", egui_phosphor::regular::TRASH))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu delete clicked for template: {:?}",
+                    item.get("template_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextDelete(item.clone()));
+                ui.close();
+            }
         }
     }
 

+ 11 - 5
src/ui/login.rs

@@ -238,11 +238,17 @@ impl LoginScreen {
                                 response.success
                             );
                             if response.success {
-                                log::info!(
-                                    "Login successfulf for user: {}",
-                                    response.user.username
-                                );
-                                Ok((server_url, response))
+                                if let Some(user) = &response.user {
+                                    log::info!(
+                                        "Login successfulf for user: {}",
+                                        user.username
+                                    );
+                                    Ok((server_url, response))
+                                } else {
+                                    let error = "Login successful but no user data returned".to_string();
+                                    log::error!("{}", error);
+                                    Err(error)
+                                }
                             } else {
                                 let error = "Login failed gay credentials".to_string();
                                 log::error!("{}", error);

+ 68 - 50
src/ui/printers.rs

@@ -405,6 +405,7 @@ impl PrintersView {
         api_client: Option<&ApiClient>,
         ribbon_ui: Option<&mut RibbonUI>,
         session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+        permissions: Option<&serde_json::Value>,
     ) {
         self.ensure_loaded(api_client);
 
@@ -426,14 +427,16 @@ impl PrintersView {
                 .copied()
                 .unwrap_or(false)
             {
-                // Provide default values - printer_settings will get plugin-specific template
-                let default_data = serde_json::json!({
-                    "printer_settings": "{}",
-                    "log": true,
-                    "can_be_used_for_reports": false,
-                    "min_powerlevel_to_use": "0"
-                });
-                self.add_dialog.open(&default_data);
+                if RibbonUI::check_permission(permissions, "create_printer") {
+                    // Provide default values - printer_settings will get plugin-specific template
+                    let default_data = serde_json::json!({
+                        "printer_settings": "{}",
+                        "log": true,
+                        "can_be_used_for_reports": false,
+                        "min_powerlevel_to_use": "0"
+                    });
+                    self.add_dialog.open(&default_data);
+                }
             }
             if ribbon
                 .checkboxes
@@ -509,7 +512,7 @@ impl PrintersView {
         }
 
         // Render table with event handling
-        self.render_table_with_events(ui, api_client);
+        self.render_table_with_events(ui, api_client, permissions);
 
         // Handle dialogs
         self.handle_dialogs(ui, api_client);
@@ -548,7 +551,12 @@ impl PrintersView {
             .insert("_printers_list".to_string(), printers_json);
     }
 
-    fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+    fn render_table_with_events(
+        &mut self,
+        ui: &mut egui::Ui,
+        api_client: Option<&ApiClient>,
+        permissions: Option<&serde_json::Value>,
+    ) {
         let printers_clone = self.printers.clone();
         let prepared_data = self.table_renderer.prepare_json_data(&printers_clone);
 
@@ -556,6 +564,7 @@ impl PrintersView {
         let mut temp_handler = TempPrintersEventHandler {
             api_client,
             deferred_actions: &mut deferred_actions,
+            permissions,
         };
 
         self.table_renderer
@@ -880,60 +889,69 @@ struct TempPrintersEventHandler<'a> {
     #[allow(dead_code)]
     api_client: Option<&'a ApiClient>,
     deferred_actions: &'a mut Vec<DeferredAction>,
+    permissions: Option<&'a serde_json::Value>,
 }
 
 impl<'a> TableEventHandler<Value> for TempPrintersEventHandler<'a> {
     fn on_double_click(&mut self, item: &Value, _row_index: usize) {
-        log::info!(
-            "Double-click detected on printer: {:?}",
-            item.get("printer_name")
-        );
-        self.deferred_actions
-            .push(DeferredAction::DoubleClick(item.clone()));
-    }
-
-    fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
-        if ui
-            .button(format!("{} Edit Printer", egui_phosphor::regular::PENCIL))
-            .clicked()
-        {
+        if RibbonUI::check_permission(self.permissions, "edit_printer") {
             log::info!(
-                "Context menu edit clicked for printer: {:?}",
+                "Double-click detected on printer: {:?}",
                 item.get("printer_name")
             );
             self.deferred_actions
-                .push(DeferredAction::ContextEdit(item.clone()));
-            ui.close();
+                .push(DeferredAction::DoubleClick(item.clone()));
         }
+    }
 
-        ui.separator();
+    fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
+        if RibbonUI::check_permission(self.permissions, "edit_printer") {
+            if ui
+                .button(format!("{} Edit Printer", egui_phosphor::regular::PENCIL))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu edit clicked for printer: {:?}",
+                    item.get("printer_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextEdit(item.clone()));
+                ui.close();
+            }
 
-        if ui
-            .button(format!("{} Clone Printer", egui_phosphor::regular::COPY))
-            .clicked()
-        {
-            log::info!(
-                "Context menu clone clicked for printer: {:?}",
-                item.get("printer_name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextClone(item.clone()));
-            ui.close();
+            ui.separator();
         }
 
-        ui.separator();
+        if RibbonUI::check_permission(self.permissions, "create_printer") {
+            if ui
+                .button(format!("{} Clone Printer", egui_phosphor::regular::COPY))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu clone clicked for printer: {:?}",
+                    item.get("printer_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextClone(item.clone()));
+                ui.close();
+            }
 
-        if ui
-            .button(format!("{} Delete Printer", egui_phosphor::regular::TRASH))
-            .clicked()
-        {
-            log::info!(
-                "Context menu delete clicked for printer: {:?}",
-                item.get("printer_name")
-            );
-            self.deferred_actions
-                .push(DeferredAction::ContextDelete(item.clone()));
-            ui.close();
+            ui.separator();
+        }
+
+        if RibbonUI::check_permission(self.permissions, "delete_printer") {
+            if ui
+                .button(format!("{} Delete Printer", egui_phosphor::regular::TRASH))
+                .clicked()
+            {
+                log::info!(
+                    "Context menu delete clicked for printer: {:?}",
+                    item.get("printer_name")
+                );
+                self.deferred_actions
+                    .push(DeferredAction::ContextDelete(item.clone()));
+                ui.close();
+            }
         }
     }
 

+ 367 - 191
src/ui/ribbon.rs

@@ -37,7 +37,102 @@ impl RibbonUI {
         Some(self.active_tab.clone())
     }
 
-    pub fn show(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) -> Option<String> {
+    pub fn check_permission(permissions: Option<&serde_json::Value>, key: &str) -> bool {
+        if let Some(perms) = permissions {
+            // If permissions is a boolean map (legacy/simple), handle it
+            if let Some(val) = perms.get(key) {
+                if let Some(b) = val.as_bool() {
+                    return b;
+                }
+            }
+
+            // If permissions is a table map (RBAC), map key to table permission
+            // Helper to check table permission
+            let check_table = |table: &str, require_write: bool| -> bool {
+                if let Some(p_val) = perms.get(table) {
+                    if let Some(p_str) = p_val.as_str() {
+                        if require_write {
+                            return p_str == "w" || p_str == "rw";
+                        } else {
+                            return p_str == "r" || p_str == "rw";
+                        }
+                    }
+                }
+                false
+            };
+
+            match key {
+                // Views
+                "view_dashboard" => true, // Everyone can see dashboard
+                "view_inventory" => check_table("assets", false),
+                "view_categories" => check_table("categories", false),
+                "view_zones" => check_table("zones", false),
+                "view_borrowing" => check_table("assets", false), // Needs assets read
+                "view_audits" => check_table("audit_tasks", false) || check_table("physical_audits", false),
+                "view_suppliers" => check_table("suppliers", false),
+                "view_issues" => check_table("issue_tracker", false),
+                "view_printers" => check_table("printer_settings", false),
+                "view_label_templates" => check_table("label_templates", false),
+                "view_item_templates" => check_table("templates", false),
+
+                // Inventory Actions
+                "create_asset" => check_table("assets", true),
+                "edit_asset" => check_table("assets", true),
+                "delete_asset" => check_table("assets", true),
+                
+                // Borrowing Actions
+                "checkout_asset" => check_table("assets", true), // Modifies asset status
+                "return_asset" => check_table("assets", true),
+                "create_borrower" => check_table("borrowers", true),
+                "edit_borrower" => check_table("borrowers", true),
+                "delete_borrower" => check_table("borrowers", true),
+
+                // Audit Actions
+                "create_audit" => check_table("physical_audits", true),
+                
+                // Supplier Actions
+                "create_supplier" => check_table("suppliers", true),
+                "edit_supplier" => check_table("suppliers", true),
+                "delete_supplier" => check_table("suppliers", true),
+                
+                // Issue Actions
+                "report_issue" => check_table("issue_tracker", true),
+                "view_issue" => check_table("issue_tracker", false),
+                "edit_issue" => check_table("issue_tracker", true),
+                "delete_issue" => check_table("issue_tracker", true),
+                "resolve_issue" => check_table("issue_tracker", true),
+
+                // Category Actions
+                "create_category" => check_table("categories", true),
+                "edit_category" => check_table("categories", true),
+                "delete_category" => check_table("categories", true),
+
+                // Zone Actions
+                "create_zone" => check_table("zones", true),
+                "edit_zone" => check_table("zones", true),
+                "delete_zone" => check_table("zones", true),
+                
+                // Printer Actions
+                "create_printer" => check_table("printer_settings", true),
+                
+                // Label Template Actions
+                "create_label_template" => check_table("label_templates", true),
+
+                // Template Actions
+                "create_template" => check_table("templates", true),
+                "edit_template" => check_table("templates", true),
+                "delete_template" => check_table("templates", true),
+
+                // Fallback
+                _ => false,
+            }
+        } else {
+            // If no permissions provided (legacy/dev), allow everything
+            true
+        }
+    }
+
+    pub fn show(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui, permissions: Option<&serde_json::Value>) -> Option<String> {
         // Clear one-shot trigger flags from previous frame so clicks only fire once
         // NOTE: inventory_filter_changed and templates_filter_changed are NOT cleared here - they're cleared by their views after processing
         for key in [
@@ -64,27 +159,30 @@ impl RibbonUI {
         ui.vertical(|ui| {
             // Tab headers row
             ui.horizontal(|ui| {
-                let tabs = vec![
-                    "Dashboard",
-                    "Inventory",
-                    "Categories",
-                    "Zones",
-                    "Borrowing",
-                    "Audits",
-                    "Suppliers",
-                    "Issues",
-                    "Printers",
-                    "Label Templates",
-                    "Item Templates",
+                let all_tabs = vec![
+                    ("Dashboard", "view_dashboard"),
+                    ("Inventory", "view_inventory"),
+                    ("Categories", "view_categories"),
+                    ("Zones", "view_zones"),
+                    ("Borrowing", "view_borrowing"),
+                    ("Audits", "view_audits"),
+                    ("Suppliers", "view_suppliers"),
+                    ("Issues", "view_issues"),
+                    ("Printers", "view_printers"),
+                    ("Label Templates", "view_label_templates"),
+                    ("Item Templates", "view_item_templates"),
                 ];
-                for tab in &tabs {
-                    if ui.selectable_label(self.active_tab == *tab, *tab).clicked() {
-                        self.active_tab = tab.to_string();
-                        // Update filter columns based on the active tab
-                        match *tab {
-                            "Zones" => self.filter_builder.set_columns_for_context("zones"),
-                            "Inventory" => self.filter_builder.set_columns_for_context("assets"),
-                            _ => {}
+                
+                for (tab, perm) in &all_tabs {
+                    if Self::check_permission(permissions, perm) {
+                        if ui.selectable_label(self.active_tab == *tab, *tab).clicked() {
+                            self.active_tab = tab.to_string();
+                            // Update filter columns based on the active tab
+                            match *tab {
+                                "Zones" => self.filter_builder.set_columns_for_context("zones"),
+                                "Inventory" => self.filter_builder.set_columns_for_context("assets"),
+                                _ => {}
+                            }
                         }
                     }
                 }
@@ -100,17 +198,17 @@ impl RibbonUI {
                 |ui| {
                     egui::ScrollArea::horizontal().show(ui, |ui| {
                         ui.horizontal(|ui| match self.active_tab.as_str() {
-                            "Dashboard" => self.show_dashboard_tab(ui, ribbon_height),
-                            "Inventory" => self.show_inventory_tab(ui, ribbon_height),
-                            "Categories" => self.show_categories_tab(ui, ribbon_height),
-                            "Zones" => self.show_zones_tab(ui, ribbon_height),
-                            "Borrowing" => self.show_borrowing_tab(ui, ribbon_height),
-                            "Audits" => self.show_audits_tab(ui, ribbon_height),
-                            "Item Templates" => self.show_templates_tab(ui, ribbon_height),
-                            "Suppliers" => self.show_suppliers_tab(ui, ribbon_height),
-                            "Issues" => self.show_issues_tab(ui, ribbon_height),
-                            "Printers" => self.show_printers_tab(ui, ribbon_height),
-                            "Label Templates" => self.show_label_templates_tab(ui, ribbon_height),
+                            "Dashboard" => self.show_dashboard_tab(ui, ribbon_height, permissions),
+                            "Inventory" => self.show_inventory_tab(ui, ribbon_height, permissions),
+                            "Categories" => self.show_categories_tab(ui, ribbon_height, permissions),
+                            "Zones" => self.show_zones_tab(ui, ribbon_height, permissions),
+                            "Borrowing" => self.show_borrowing_tab(ui, ribbon_height, permissions),
+                            "Audits" => self.show_audits_tab(ui, ribbon_height, permissions),
+                            "Item Templates" => self.show_templates_tab(ui, ribbon_height, permissions),
+                            "Suppliers" => self.show_suppliers_tab(ui, ribbon_height, permissions),
+                            "Issues" => self.show_issues_tab(ui, ribbon_height, permissions),
+                            "Printers" => self.show_printers_tab(ui, ribbon_height, permissions),
+                            "Label Templates" => self.show_label_templates_tab(ui, ribbon_height, permissions),
                             _ => {}
                         });
                     });
@@ -172,7 +270,7 @@ impl RibbonUI {
             });
     }
 
-    fn show_dashboard_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_dashboard_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, _permissions: Option<&serde_json::Value>) {
         ui.group(|ui| {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
@@ -207,7 +305,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_inventory_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_inventory_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         ui.group(|ui| {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
@@ -242,42 +340,54 @@ impl RibbonUI {
                                 let idx = col * rows + row;
                                 let w = col_widths[col];
                                 let button = egui::Button::new(labels[idx]);
-                                let clicked = ui.add_sized([w, row_height], button).clicked();
-                                if clicked {
-                                    // If user holds Alt while clicking Edit, trigger Advanced Edit instead of Easy
-                                    let alt_held = ui.input(|i| i.modifiers.alt);
-                                    match idx {
-                                        0 => {
-                                            self.checkboxes
-                                                .insert("inventory_action_add".to_string(), true);
-                                        }
-                                        1 => {
-                                            if alt_held {
+                                
+                                let enabled = match idx {
+                                    0 => Self::check_permission(permissions, "create_asset"),
+                                    1 => Self::check_permission(permissions, "edit_asset"),
+                                    2 => Self::check_permission(permissions, "delete_asset"),
+                                    _ => true,
+                                };
+
+                                if enabled {
+                                    let clicked = ui.add_sized([w, row_height], button).clicked();
+                                    if clicked {
+                                        // If user holds Alt while clicking Edit, trigger Advanced Edit instead of Easy
+                                        let alt_held = ui.input(|i| i.modifiers.alt);
+                                        match idx {
+                                            0 => {
+                                                self.checkboxes
+                                                    .insert("inventory_action_add".to_string(), true);
+                                            }
+                                            1 => {
+                                                if alt_held {
+                                                    self.checkboxes.insert(
+                                                        "inventory_action_edit_adv".to_string(),
+                                                        true,
+                                                    );
+                                                } else {
+                                                    self.checkboxes.insert(
+                                                        "inventory_action_edit_easy".to_string(),
+                                                        true,
+                                                    );
+                                                }
+                                            }
+                                            2 => {
                                                 self.checkboxes.insert(
-                                                    "inventory_action_edit_adv".to_string(),
+                                                    "inventory_action_delete".to_string(),
                                                     true,
                                                 );
-                                            } else {
+                                            }
+                                            3 => {
                                                 self.checkboxes.insert(
-                                                    "inventory_action_edit_easy".to_string(),
+                                                    "inventory_action_print_label".to_string(),
                                                     true,
                                                 );
                                             }
+                                            _ => {}
                                         }
-                                        2 => {
-                                            self.checkboxes.insert(
-                                                "inventory_action_delete".to_string(),
-                                                true,
-                                            );
-                                        }
-                                        3 => {
-                                            self.checkboxes.insert(
-                                                "inventory_action_print_label".to_string(),
-                                                true,
-                                            );
-                                        }
-                                        _ => {}
                                     }
+                                } else {
+                                    ui.allocate_space(egui::vec2(w, row_height));
                                 }
                             }
                             ui.end_row();
@@ -341,51 +451,53 @@ impl RibbonUI {
                         let w2 = 8.0 * "Add from...".len() as f32 + pad_x * 2.0;
                         let w = w1.max(w2).max(130.0);
 
-                        if ui
-                            .add_sized([w, 24.0], egui::Button::new("Inventarize Room"))
-                            .clicked()
-                        {
-                            self.checkboxes
-                                .insert("inventory_quick_inventarize_room".to_string(), true);
-                        }
+                        if Self::check_permission(permissions, "create_asset") {
+                            if ui
+                                .add_sized([w, 24.0], egui::Button::new("Inventarize Room"))
+                                .clicked()
+                            {
+                                self.checkboxes
+                                    .insert("inventory_quick_inventarize_room".to_string(), true);
+                            }
 
-                        // Use a fixed-width ComboBox as a dropdown to ensure equal width
-                        egui::ComboBox::from_id_salt("inventory_add_from_combo")
-                            .width(w)
-                            .selected_text("Add from...")
-                            .show_ui(ui, |ui| {
-                                if ui.selectable_label(false, "Add from Template").clicked() {
-                                    self.checkboxes.insert(
-                                        "inventory_add_from_template_single".to_string(),
-                                        true,
-                                    );
-                                }
-                                if ui
-                                    .selectable_label(false, "Add Multiple from Template")
-                                    .clicked()
-                                {
-                                    self.checkboxes.insert(
-                                        "inventory_add_from_template_multiple".to_string(),
-                                        true,
-                                    );
-                                }
-                                if ui
-                                    .selectable_label(false, "Add using Another Item")
-                                    .clicked()
-                                {
-                                    self.checkboxes
-                                        .insert("inventory_add_from_item_single".to_string(), true);
-                                }
-                                if ui
-                                    .selectable_label(false, "Add Multiple from Another Item")
-                                    .clicked()
-                                {
-                                    self.checkboxes.insert(
-                                        "inventory_add_from_item_multiple".to_string(),
-                                        true,
-                                    );
-                                }
-                            });
+                            // Use a fixed-width ComboBox as a dropdown to ensure equal width
+                            egui::ComboBox::from_id_salt("inventory_add_from_combo")
+                                .width(w)
+                                .selected_text("Add from...")
+                                .show_ui(ui, |ui| {
+                                    if ui.selectable_label(false, "Add from Template").clicked() {
+                                        self.checkboxes.insert(
+                                            "inventory_add_from_template_single".to_string(),
+                                            true,
+                                        );
+                                    }
+                                    if ui
+                                        .selectable_label(false, "Add Multiple from Template")
+                                        .clicked()
+                                    {
+                                        self.checkboxes.insert(
+                                            "inventory_add_from_template_multiple".to_string(),
+                                            true,
+                                        );
+                                    }
+                                    if ui
+                                        .selectable_label(false, "Add using Another Item")
+                                        .clicked()
+                                    {
+                                        self.checkboxes
+                                            .insert("inventory_add_from_item_single".to_string(), true);
+                                    }
+                                    if ui
+                                        .selectable_label(false, "Add Multiple from Another Item")
+                                        .clicked()
+                                    {
+                                        self.checkboxes.insert(
+                                            "inventory_add_from_item_multiple".to_string(),
+                                            true,
+                                        );
+                                    }
+                                });
+                        }
                     });
             });
         });
@@ -481,7 +593,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_categories_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_categories_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in [
             "categories_refresh",
@@ -497,25 +609,35 @@ impl RibbonUI {
             ui.vertical(|ui| {
                 ui.label("Actions");
                 // Match Inventory layout: [Add, Edit, Delete, Refresh] (Refresh where Inventory has Print)
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_category") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "Add Category"),
                         "categories_add",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "edit_category") {
+                    items.push((
                         format!("{} {}", icons::PENCIL, "Edit Category"),
                         "categories_edit",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "delete_category") {
+                    items.push((
                         format!("{} {}", icons::TRASH, "Delete Category"),
                         "categories_delete",
-                    ),
-                    (
-                        format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
-                        "categories_refresh",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "categories_actions_grid", &items);
+                    ));
+                }
+                
+                items.push((
+                    format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
+                    "categories_refresh",
+                ));
+
+                // Convert to slice of references for the helper
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "categories_actions_grid", &items_refs);
             });
         });
 
@@ -536,7 +658,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_zones_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_zones_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in [
             "zones_action_add",
@@ -550,17 +672,23 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_zone") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "Add Zone"),
                         "zones_action_add",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "edit_zone") {
+                    items.push((
                         format!("{} {}", icons::PENCIL, "Edit Zone"),
                         "zones_action_edit",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "zones_actions_grid", &items);
+                    ));
+                }
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "zones_actions_grid", &items_refs);
             });
         });
         ui.group(|ui| {
@@ -612,7 +740,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_borrowing_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_borrowing_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in [
             "borrowing_action_checkout",
@@ -627,25 +755,34 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "checkout_asset") {
+                    items.push((
                         format!("{} {}", icons::ARROW_LEFT, "Check Out"),
                         "borrowing_action_checkout",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "return_asset") {
+                    items.push((
                         format!("{} {}", icons::ARROW_RIGHT, "Return"),
                         "borrowing_action_return",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "register_borrower") {
+                    items.push((
                         format!("{} {}", icons::PENCIL, "Register"),
                         "borrowing_action_register",
-                    ),
-                    (
-                        format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
-                        "borrowing_action_refresh",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "borrowing_actions_grid", &items);
+                    ));
+                }
+                
+                items.push((
+                    format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
+                    "borrowing_action_refresh",
+                ));
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "borrowing_actions_grid", &items_refs);
             });
         });
         ui.group(|ui| {
@@ -668,22 +805,30 @@ impl RibbonUI {
         });
     }
 
-    fn show_audits_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_audits_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         ui.group(|ui| {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    ("New Audit".to_string(), "audits_action_new"),
-                    ("View Audit".to_string(), "audits_action_view"),
-                    ("Export Report".to_string(), "audits_action_export"),
-                ];
-                self.render_actions_grid_with_keys(ui, "audits_actions_grid", &items);
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_audit") {
+                    items.push(("New Audit".to_string(), "audits_action_new"));
+                }
+                if Self::check_permission(permissions, "view_audit") {
+                    items.push(("View Audit".to_string(), "audits_action_view"));
+                }
+                if Self::check_permission(permissions, "export_audit") {
+                    items.push(("Export Report".to_string(), "audits_action_export"));
+                }
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "audits_actions_grid", &items_refs);
             });
         });
     }
 
-    fn show_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in [
             "templates_action_new",
@@ -697,17 +842,23 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_template") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "Add Template"),
                         "templates_action_new",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "edit_template") {
+                    items.push((
                         format!("{} {}", icons::PENCIL, "Edit Template"),
                         "templates_action_edit",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "templates_actions_grid", &items);
+                    ));
+                }
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "templates_actions_grid", &items_refs);
             });
         });
         // Import/Export removed for now
@@ -765,17 +916,25 @@ impl RibbonUI {
         });
     }
 
-    fn show_issues_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_issues_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         ui.group(|ui| {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    ("Report Issue".to_string(), "issues_action_report"),
-                    ("View Issue".to_string(), "issues_action_view"),
-                    ("Resolve Issue".to_string(), "issues_action_resolve"),
-                ];
-                self.render_actions_grid_with_keys(ui, "issues_actions_grid", &items);
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "report_issue") {
+                    items.push(("Report Issue".to_string(), "issues_action_report"));
+                }
+                if Self::check_permission(permissions, "view_issue") {
+                    items.push(("View Issue".to_string(), "issues_action_view"));
+                }
+                if Self::check_permission(permissions, "resolve_issue") {
+                    items.push(("Resolve Issue".to_string(), "issues_action_resolve"));
+                }
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "issues_actions_grid", &items_refs);
             });
         });
         ui.group(|ui| {
@@ -817,7 +976,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_suppliers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_suppliers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         for key in [
             "suppliers_action_new",
             "suppliers_action_edit",
@@ -830,17 +989,23 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_supplier") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "New Supplier"),
                         "suppliers_action_new",
-                    ),
-                    (
+                    ));
+                }
+                if Self::check_permission(permissions, "edit_supplier") {
+                    items.push((
                         format!("{} {}", icons::PENCIL, "Edit Supplier"),
                         "suppliers_action_edit",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "suppliers_actions_grid", &items);
+                    ));
+                }
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "suppliers_actions_grid", &items_refs);
             });
         });
         ui.group(|ui| {
@@ -860,7 +1025,7 @@ impl RibbonUI {
         });
     }
 
-    pub fn show_printers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    pub fn show_printers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in [
             "printers_action_add",
@@ -875,17 +1040,22 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_printer") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "Add Printer"),
                         "printers_action_add",
-                    ),
-                    (
-                        format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
-                        "printers_action_refresh",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "printers_actions_grid", &items);
+                    ));
+                }
+                
+                items.push((
+                    format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
+                    "printers_action_refresh",
+                ));
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "printers_actions_grid", &items_refs);
             });
         });
         ui.group(|ui| {
@@ -1011,7 +1181,7 @@ impl RibbonUI {
         });
     }
 
-    fn show_label_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) {
+    fn show_label_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32, permissions: Option<&serde_json::Value>) {
         // Clear one-shot action triggers from previous frame
         for key in ["labels_action_add", "labels_action_refresh"] {
             self.checkboxes.insert(key.to_string(), false);
@@ -1021,19 +1191,25 @@ impl RibbonUI {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {
                 ui.label("Actions");
-                let items = vec![
-                    (
+                let mut items = Vec::new();
+                
+                if Self::check_permission(permissions, "create_label_template") {
+                    items.push((
                         format!("{} {}", icons::PLUS, "Add Template"),
                         "labels_action_add",
-                    ),
-                    (
-                        format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
-                        "labels_action_refresh",
-                    ),
-                ];
-                self.render_actions_grid_with_keys(ui, "labels_actions_grid", &items);
+                    ));
+                }
+                
+                items.push((
+                    format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"),
+                    "labels_action_refresh",
+                ));
+                
+                let items_refs: Vec<(String, &str)> = items.iter().map(|(s, k)| (s.clone(), *k)).collect();
+                self.render_actions_grid_with_keys(ui, "labels_actions_grid", &items_refs);
             });
         });
+
         ui.group(|ui| {
             ui.set_height(ribbon_height);
             ui.vertical(|ui| {

+ 17 - 10
src/ui/suppliers.rs

@@ -238,6 +238,7 @@ impl SuppliersView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon: Option<&mut RibbonUI>,
+        permissions: Option<&serde_json::Value>,
     ) -> Vec<String> {
         let mut flags_to_clear = Vec::new();
 
@@ -277,7 +278,9 @@ impl SuppliersView {
                 .copied()
                 .unwrap_or(false)
             {
-                self.add_dialog.open_new(None);
+                if RibbonUI::check_permission(permissions, "create_supplier") {
+                    self.add_dialog.open_new(None);
+                }
                 flags_to_clear.push("suppliers_action_new".to_string());
             }
 
@@ -287,10 +290,12 @@ impl SuppliersView {
                 .copied()
                 .unwrap_or(false)
             {
-                if let Some(selected) = self.first_selected_supplier() {
-                    self.open_editor_with(&selected);
-                } else {
-                    log::warn!("Ribbon edit triggered but no supplier selected");
+                if RibbonUI::check_permission(permissions, "edit_supplier") {
+                    if let Some(selected) = self.first_selected_supplier() {
+                        self.open_editor_with(&selected);
+                    } else {
+                        log::warn!("Ribbon edit triggered but no supplier selected");
+                    }
                 }
                 flags_to_clear.push("suppliers_action_edit".to_string());
             }
@@ -301,11 +306,13 @@ impl SuppliersView {
                 .copied()
                 .unwrap_or(false)
             {
-                if let Some((name, id)) = self.first_selected_supplier_id_name() {
-                    self.delete_current_id = Some(id);
-                    self.delete_dialog.open(name, id.to_string());
-                } else {
-                    log::warn!("Ribbon delete triggered but no supplier selected");
+                if RibbonUI::check_permission(permissions, "delete_supplier") {
+                    if let Some((name, id)) = self.first_selected_supplier_id_name() {
+                        self.delete_current_id = Some(id);
+                        self.delete_dialog.open(name, id.to_string());
+                    } else {
+                        log::warn!("Ribbon delete triggered but no supplier selected");
+                    }
                 }
                 flags_to_clear.push("suppliers_action_delete".to_string());
             }

+ 51 - 44
src/ui/templates.rs

@@ -433,6 +433,7 @@ impl TemplatesView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon: Option<&mut crate::ui::ribbon::RibbonUI>,
+        permissions: Option<&serde_json::Value>,
     ) -> Vec<String> {
         let mut flags_to_clear = Vec::new();
 
@@ -535,43 +536,45 @@ impl TemplatesView {
             {
                 flags_to_clear.push("templates_action_new".to_string());
 
-                // Prepare dynamic dropdown fields before opening dialog
-                if let Some(client) = api_client {
-                    self.prepare_template_edit_fields(client);
-                }
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_template") {
+                    // Prepare dynamic dropdown fields before opening dialog
+                    if let Some(client) = api_client {
+                        self.prepare_template_edit_fields(client);
+                    }
 
-                // Open new template dialog with empty data (comprehensive fields for templates)
-                let empty_template = serde_json::json!({
-                    "id": "",
-                    "template_code": "",
-                    "name": "",
-                    "asset_type": "",
-                    "asset_tag_generation_string": "",
-                    "description": "",
-                    "additional_fields": null,
-                    "additional_fields_json": "{}",
-                    "category_id": "",
-                    "manufacturer": "",
-                    "model": "",
-                    "zone_id": "",
-                    "zone_plus": "",
-                    "status": "",
-                    "price": "",
-                    "warranty_until": "",
-                    "expiry_date": "",
-                    "quantity_total": "",
-                    "quantity_used": "",
-                    "supplier_id": "",
-                    "label_template_id": "",
-                    "audit_task_id": "",
-                    "lendable": false,
-                    "minimum_role_for_lending": "",
-                    "no_scan": "",
-                    "notes": "",
-                    "active": false
-                });
-                self.edit_dialog.title = "Add New Template".to_string();
-                self.open_edit_template_dialog(empty_template);
+                    // Open new template dialog with empty data (comprehensive fields for templates)
+                    let empty_template = serde_json::json!({
+                        "id": "",
+                        "template_code": "",
+                        "name": "",
+                        "asset_type": "",
+                        "asset_tag_generation_string": "",
+                        "description": "",
+                        "additional_fields": null,
+                        "additional_fields_json": "{}",
+                        "category_id": "",
+                        "manufacturer": "",
+                        "model": "",
+                        "zone_id": "",
+                        "zone_plus": "",
+                        "status": "",
+                        "price": "",
+                        "warranty_until": "",
+                        "expiry_date": "",
+                        "quantity_total": "",
+                        "quantity_used": "",
+                        "supplier_id": "",
+                        "label_template_id": "",
+                        "audit_task_id": "",
+                        "lendable": false,
+                        "minimum_role_for_lending": "",
+                        "no_scan": "",
+                        "notes": "",
+                        "active": false
+                    });
+                    self.edit_dialog.title = "Add New Template".to_string();
+                    self.open_edit_template_dialog(empty_template);
+                }
             }
 
             if *ribbon
@@ -580,10 +583,12 @@ impl TemplatesView {
                 .unwrap_or(&false)
             {
                 flags_to_clear.push("templates_action_edit".to_string());
-                // TODO: Implement edit selected template (requires selection tracking)
-                log::info!(
-                    "Edit Template clicked (requires table selection - use double-click for now)"
-                );
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_template") {
+                    // TODO: Implement edit selected template (requires selection tracking)
+                    log::info!(
+                        "Edit Template clicked (requires table selection - use double-click for now)"
+                    );
+                }
             }
 
             if *ribbon
@@ -592,10 +597,12 @@ impl TemplatesView {
                 .unwrap_or(&false)
             {
                 flags_to_clear.push("templates_action_delete".to_string());
-                // TODO: Implement delete selected templates (requires selection tracking)
-                log::info!(
-                    "Delete Template clicked (requires table selection - use right-click for now)"
-                );
+                if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_template") {
+                    // TODO: Implement delete selected templates (requires selection tracking)
+                    log::info!(
+                        "Delete Template clicked (requires table selection - use right-click for now)"
+                    );
+                }
             }
         }
 

+ 115 - 100
src/ui/zones.rs

@@ -315,6 +315,7 @@ impl ZonesView {
         ui: &mut egui::Ui,
         api_client: Option<&ApiClient>,
         ribbon: &mut crate::ui::ribbon::RibbonUI,
+        permissions: Option<&serde_json::Value>,
     ) {
         self.ensure_loaded(api_client);
 
@@ -352,8 +353,10 @@ impl ZonesView {
             .copied()
             .unwrap_or(false)
         {
-            self.update_editor_dropdowns();
-            self.add_dialog.open_new(None);
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_zone") {
+                self.update_editor_dropdowns();
+                self.add_dialog.open_new(None);
+            }
         }
         if ribbon
             .checkboxes
@@ -361,8 +364,10 @@ impl ZonesView {
             .copied()
             .unwrap_or(false)
         {
-            // Edit needs a selected zone - will be handled via context menu
-            log::info!("Edit zone clicked - use right-click context menu on a zone");
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_zone") {
+                // Edit needs a selected zone - will be handled via context menu
+                log::info!("Edit zone clicked - use right-click context menu on a zone");
+            }
         }
         if ribbon
             .checkboxes
@@ -370,8 +375,10 @@ impl ZonesView {
             .copied()
             .unwrap_or(false)
         {
-            // Remove needs a selected zone - will be handled via context menu
-            log::info!("Remove zone clicked - use right-click context menu on a zone");
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_zone") {
+                // Remove needs a selected zone - will be handled via context menu
+                log::info!("Remove zone clicked - use right-click context menu on a zone");
+            }
         }
 
         // Update show_items from ribbon
@@ -503,7 +510,7 @@ impl ZonesView {
                 .auto_shrink([false; 2])
                 .show(ui, |ui| {
                     for root in roots {
-                        self.render_zone_node(ui, root, &children, api_client);
+                        self.render_zone_node(ui, root, &children, api_client, permissions);
                     }
                 });
         }
@@ -561,6 +568,7 @@ impl ZonesView {
         zone: &serde_json::Value,
         children: &HashMap<Option<i32>, Vec<serde_json::Value>>,
         api_client: Option<&ApiClient>,
+        permissions: Option<&serde_json::Value>,
     ) {
         let id = zone.get("id").and_then(|v| v.as_i64()).unwrap_or_default() as i32;
         let code = zone
@@ -597,7 +605,7 @@ impl ZonesView {
             if let Some(kids) = children.get(&Some(id)) {
                 ui.indent(egui::Id::new(("zone_indent", id)), |ui| {
                     for child in kids {
-                        self.render_zone_node(ui, child, children, api_client);
+                        self.render_zone_node(ui, child, children, api_client, permissions);
                     }
                 });
             }
@@ -651,64 +659,69 @@ impl ZonesView {
 
         // Add context menu to header for editing
         resp.header_response.context_menu(|ui| {
-            if ui
-                .button(format!("{} Edit Zone", egui_phosphor::regular::PENCIL))
-                .clicked()
-            {
-                // Update dropdowns with current zone list
-                self.update_editor_dropdowns();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "edit_zone") {
+                if ui
+                    .button(format!("{} Edit Zone", egui_phosphor::regular::PENCIL))
+                    .clicked()
+                {
+                    // Update dropdowns with current zone list
+                    self.update_editor_dropdowns();
 
-                // Map the zone data to match the editor field names
-                if let Some(zone_obj) = zone.as_object() {
-                    let mut zone_for_editor = zone_obj.clone();
+                    // Map the zone data to match the editor field names
+                    if let Some(zone_obj) = zone.as_object() {
+                        let mut zone_for_editor = zone_obj.clone();
 
-                    // The data comes in with "name" (aliased from zone_name), but editor expects "zone_name"
-                    if let Some(name_value) = zone_for_editor.remove("name") {
-                        zone_for_editor.insert("zone_name".to_string(), name_value);
-                    }
+                        // The data comes in with "name" (aliased from zone_name), but editor expects "zone_name"
+                        if let Some(name_value) = zone_for_editor.remove("name") {
+                            zone_for_editor.insert("zone_name".to_string(), name_value);
+                        }
 
-                    // Convert integer fields to strings if they're numbers
-                    if let Some(audit_timeout) = zone_for_editor.get("audit_timeout_minutes") {
-                        if let Some(num) = audit_timeout.as_i64() {
-                            zone_for_editor.insert(
-                                "audit_timeout_minutes".to_string(),
-                                serde_json::json!(num.to_string()),
-                            );
+                        // Convert integer fields to strings if they're numbers
+                        if let Some(audit_timeout) = zone_for_editor.get("audit_timeout_minutes") {
+                            if let Some(num) = audit_timeout.as_i64() {
+                                zone_for_editor.insert(
+                                    "audit_timeout_minutes".to_string(),
+                                    serde_json::json!(num.to_string()),
+                                );
+                            }
                         }
-                    }
 
-                    self.edit_dialog
-                        .open(&serde_json::Value::Object(zone_for_editor));
-                    ui.close();
-                } else {
-                    log::error!("Zone data is not an object, cannot edit");
+                        self.edit_dialog
+                            .open(&serde_json::Value::Object(zone_for_editor));
+                        ui.close();
+                    } else {
+                        log::error!("Zone data is not an object, cannot edit");
+                    }
                 }
             }
-            if ui
-                .button(format!("{} Delete Zone", egui_phosphor::regular::TRASH))
-                .clicked()
-            {
-                self.pending_delete_id = Some(id);
-                self.delete_dialog
-                    .open(format!("{} - {}", code, name), id.to_string());
-                ui.close();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "delete_zone") {
+                if ui
+                    .button(format!("{} Delete Zone", egui_phosphor::regular::TRASH))
+                    .clicked()
+                {
+                    self.pending_delete_id = Some(id);
+                    self.delete_dialog.open(name.to_string(), id.to_string());
+                    ui.close();
+                }
             }
             ui.separator();
-            if ui
-                .button(format!("{} Add Child Zone", egui_phosphor::regular::PLUS))
-                .clicked()
-            {
-                // Open add dialog with parent_id pre-filled
-                self.pending_parent_id = Some(id);
-                self.update_editor_dropdowns();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_zone") {
+                if ui
+                    .button(format!("{} Add Child Zone", egui_phosphor::regular::PLUS))
+                    .clicked()
+                {
+                    // Open add dialog with parent_id pre-filled
+                    self.pending_parent_id = Some(id);
+                    self.update_editor_dropdowns();
 
-                // Create initial data with parent_id
-                let mut initial_data = serde_json::Map::new();
+                    // Create initial data with parent_id
+                    let mut initial_data = serde_json::Map::new();
                 initial_data.insert("parent_id".to_string(), serde_json::json!(id.to_string()));
 
                 self.add_dialog.open_new(Some(&initial_data));
                 ui.close();
             }
+            }
             if ui
                 .button(format!(
                     "{} Show Items in this Zone",
@@ -746,63 +759,65 @@ impl ZonesView {
                 ui.close();
             }
             ui.separator();
-            if ui
-                .button(format!("{} Clone Zone", egui_phosphor::regular::COPY))
-                .clicked()
-            {
-                // Open add dialog prefilled with cloned values
-                self.update_editor_dropdowns();
+            if crate::ui::ribbon::RibbonUI::check_permission(permissions, "create_zone") {
+                if ui
+                    .button(format!("{} Clone Zone", egui_phosphor::regular::COPY))
+                    .clicked()
+                {
+                    // Open add dialog prefilled with cloned values
+                    self.update_editor_dropdowns();
 
-                // Start from original zone object
-                let mut clone_map = zone.as_object().cloned().unwrap_or_default();
+                    // Start from original zone object
+                    let mut clone_map = zone.as_object().cloned().unwrap_or_default();
 
-                // Editor expects zone_name instead of name
-                if let Some(name_val) = clone_map.remove("name") {
-                    clone_map.insert("zone_name".to_string(), name_val);
-                }
+                    // Editor expects zone_name instead of name
+                    if let Some(name_val) = clone_map.remove("name") {
+                        clone_map.insert("zone_name".to_string(), name_val);
+                    }
 
-                // Clear identifiers and codes that must be unique
-                clone_map.remove("id");
-                clone_map.insert(
-                    "zone_code".to_string(),
-                    serde_json::Value::String(String::new()),
-                );
-                // Mini code is required but typically unique — leave empty to force user choice
-                clone_map.insert(
-                    "mini_code".to_string(),
-                    serde_json::Value::String(String::new()),
-                );
-
-                // Convert parent_id to string for dropdown if present
-                if let Some(p) = clone_map.get("parent_id").cloned() {
-                    let as_str = match p {
-                        serde_json::Value::Number(n) => {
-                            n.as_i64().map(|i| i.to_string()).unwrap_or_default()
+                    // Clear identifiers and codes that must be unique
+                    clone_map.remove("id");
+                    clone_map.insert(
+                        "zone_code".to_string(),
+                        serde_json::Value::String(String::new()),
+                    );
+                    // Mini code is required but typically unique — leave empty to force user choice
+                    clone_map.insert(
+                        "mini_code".to_string(),
+                        serde_json::Value::String(String::new()),
+                    );
+
+                    // Convert parent_id to string for dropdown if present
+                    if let Some(p) = clone_map.get("parent_id").cloned() {
+                        let as_str = match p {
+                            serde_json::Value::Number(n) => {
+                                n.as_i64().map(|i| i.to_string()).unwrap_or_default()
+                            }
+                            serde_json::Value::String(s) => s,
+                            _ => String::new(),
+                        };
+                        clone_map.insert("parent_id".to_string(), serde_json::Value::String(as_str));
+                    }
+
+                    // Ensure audit_timeout_minutes is string
+                    if let Some(a) = clone_map.get("audit_timeout_minutes").cloned() {
+                        if let Some(num) = a.as_i64() {
+                            clone_map.insert(
+                                "audit_timeout_minutes".to_string(),
+                                serde_json::json!(num.to_string()),
+                            );
                         }
-                        serde_json::Value::String(s) => s,
-                        _ => String::new(),
-                    };
-                    clone_map.insert("parent_id".to_string(), serde_json::Value::String(as_str));
-                }
+                    }
 
-                // Ensure audit_timeout_minutes is string
-                if let Some(a) = clone_map.get("audit_timeout_minutes").cloned() {
-                    if let Some(num) = a.as_i64() {
-                        clone_map.insert(
-                            "audit_timeout_minutes".to_string(),
-                            serde_json::json!(num.to_string()),
-                        );
+                    // Suffix the name to indicate copy
+                    if let Some(serde_json::Value::String(nm)) = clone_map.get_mut("zone_name") {
+                        nm.push_str("");
                     }
-                }
 
-                // Suffix the name to indicate copy
-                if let Some(serde_json::Value::String(nm)) = clone_map.get_mut("zone_name") {
-                    nm.push_str("");
+                    // Open prefilled Add dialog
+                    self.add_dialog.open_new(Some(&clone_map));
+                    ui.close();
                 }
-
-                // Open prefilled Add dialog
-                self.add_dialog.open_new(Some(&clone_map));
-                ui.close();
             }
         });
     }