use crate::plm::bom::{self, DocumentLine, ServerBom};
use crate::plm::client::{PlmClient, PlmError};
use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::{json, Value};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
type Slot<T> = Rc<RefCell<Option<T>>>;
struct Fetched {
key: (String, String, bool),
slot: Slot<Result<ServerBom, String>>,
document: Result<Vec<DocumentLine>, Vec<String>>,
}
struct Patch {
part_name: String,
field: String,
value: Value,
slot: Slot<Result<(), PlmError>>,
}
#[derive(Default)]
pub struct PlmBomView {
pub active: bool,
pub flat: bool,
fetched: Option<Fetched>,
patches: Vec<Patch>,
pub last_edit: Option<Result<String, String>>,
}
fn spawn(task: impl std::future::Future<Output = ()> + 'static) {
#[cfg(target_arch = "wasm32")]
wasm_bindgen_futures::spawn_local(task);
#[cfg(not(target_arch = "wasm32"))]
crate::plm::native::spawn(task);
}
impl PlmBomView {
fn ensure(&mut self, client: &Rc<PlmClient>, part: &str, revision: &str, state: &EngineState, force: bool) {
let key = (part.to_string(), revision.to_string(), self.flat);
if !force && self.fetched.as_ref().is_some_and(|f| f.key == key) {
return;
}
let slot: Slot<Result<ServerBom, String>> = Rc::default();
let (into, client, part_id, rev, flat) = (slot.clone(), client.clone(), part.to_string(), revision.to_string(), self.flat);
spawn(async move {
let got = bom::fetch_bom(&client, &part_id, &rev, flat).await.map_err(|e| e.to_string());
*into.borrow_mut() = Some(got);
});
let document = serde_json::from_str::<Value>(&state.history_request_json())
.map_err(|e| vec![e.to_string()])
.and_then(|doc| bom::document_bom(&doc));
self.fetched = Some(Fetched { key, slot, document });
}
pub fn queue_patch(&mut self, client: &Rc<PlmClient>, part_id: &str, part_name: &str, field: &str, value: Value) {
let slot: Slot<Result<(), PlmError>> = Rc::default();
let (into, client, part_id, key, sent) = (slot.clone(), client.clone(), part_id.to_string(), field.to_string(), value.clone());
spawn(async move {
let got = bom::patch_attribute(&client, &part_id, &key, &sent).await;
*into.borrow_mut() = Some(got);
});
self.patches.push(Patch { part_name: part_name.to_string(), field: field.to_string(), value, slot });
}
pub fn settle(&mut self, state: &mut EngineState) {
while let Some(first) = self.patches.first() {
let Some(result) = first.slot.borrow_mut().take() else { break };
let patch = self.patches.remove(0);
match result {
Ok(()) => match state.set_part_attribute(&patch.part_name, &patch.field, patch.value.clone()) {
Ok(()) => {
self.last_edit = Some(Ok(format!("{} of {} saved on the PLM", patch.field, patch.part_name)));
self.fetched = None;
}
Err(error) => self.last_edit = Some(Err(format!("saved on the PLM, but not here: {error}"))),
},
Err(refused) => {
let sentence = format!("{} of {}: {refused}", patch.field, patch.part_name);
state.push_notice(format!("BOM: {sentence}"));
self.last_edit = Some(Err(sentence));
}
}
}
}
pub fn pending(&self) -> usize {
self.patches.len()
}
pub fn ui(
&mut self,
ui: &mut egui::Ui,
state: &EngineState,
client: &Rc<PlmClient>,
part: &str,
revision: &str,
hits: &mut HashMap<String, egui::Rect>,
) {
let mut refresh = false;
ui.horizontal(|ui| {
let indented = ui.selectable_label(!self.flat, "Indented").on_hover_text("Every level, as the tree it is");
hits.insert("bom:plm:indented".into(), indented.rect);
if indented.clicked() {
self.flat = false;
}
let flat = ui.selectable_label(self.flat, "Flat").on_hover_text("One line per part, totals summed over every path");
hits.insert("bom:plm:flat".into(), flat.rect);
if flat.clicked() {
self.flat = true;
}
let again = ui.button("Refresh").on_hover_text("Ask the PLM again");
hits.insert("bom:plm:refresh".into(), again.rect);
refresh = again.clicked();
});
self.ensure(client, part, revision, state, refresh);
if let Some(outcome) = &self.last_edit {
match outcome {
Ok(text) => ui.label(text),
Err(text) => ui.label(egui::RichText::new(text).color(crate::panels::assembly_components::OUTDATED_AMBER)),
};
}
let Some(fetched) = &self.fetched else { return };
let answer = fetched.slot.borrow();
let bom = match answer.as_ref() {
None => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Asking the PLM for this revision's BOM…");
});
ui.ctx().request_repaint();
return;
}
Some(Err(error)) => {
ui.label(egui::RichText::new(error).color(crate::panels::assembly_components::OUTDATED_AMBER));
return;
}
Some(Ok(bom)) => bom,
};
ui.label(format!("{} {} rev {} ({})", bom.number, bom.name, bom.revision_label, bom.state));
if !self.flat {
match &fetched.document {
Ok(lines) => {
let differences = bom::compare(lines, &bom.lines);
if differences.is_empty() {
ui.label(format!("The PLM's BOM is this document's, line for line ({} lines).", lines.len()));
} else {
ui.label(egui::RichText::new(format!("{} difference(s) from this document:", differences.len())).color(crate::panels::assembly_components::OUTDATED_AMBER));
for difference in &differences {
ui.label(difference);
}
}
}
Err(refused) => {
ui.label(egui::RichText::new("This document places parts that are no PLM revisions:").color(crate::panels::assembly_components::OUTDATED_AMBER));
for why in refused {
ui.label(why);
}
}
}
}
let grid = egui::Grid::new("bom-plm-lines").striped(true).show(ui, |ui| {
for heading in ["Pos", "Number", "Name", "Rev", "State", "Qty", "Total", "Attributes", "MPN", "Manufacturer", "Supplier", "Unit price", "Extended"] {
ui.strong(heading);
}
ui.end_row();
for line in &bom.lines {
let indent = " ".repeat(line.level.saturating_sub(1));
ui.label(if self.flat { String::new() } else { line.position.clone() });
ui.label(format!("{indent}{}", line.number));
ui.label(&line.name);
ui.label(&line.revision_label);
ui.label(if line.flags.is_empty() { line.state.clone() } else { format!("{} ({})", line.state, line.flags.join(", ")) });
ui.label(format!("{} {}", number(line.quantity), line.unit));
ui.label(number(line.total));
ui.label(line.attributes.join("; "));
ui.label(&line.mpn);
ui.label(&line.manufacturer);
ui.label(&line.supplier);
ui.label(line.unit_price.map(|p| format!("{p} {}", line.currency)).unwrap_or_default());
ui.label(line.extended.map(|p| format!("{p} {}", line.currency)).unwrap_or_default());
ui.end_row();
}
});
hits.insert("bom:plm:lines".into(), grid.response.rect);
for total in &bom.totals {
ui.label(format!("Total: {} {} over {} line(s)", total.total, total.currency, total.lines));
}
if bom.unpriced > 0 {
ui.label(format!("{} line(s) have no price, and the totals miss them.", bom.unpriced));
}
for warning in &bom.warnings {
ui.label(egui::RichText::new(warning).color(crate::panels::assembly_components::OUTDATED_AMBER));
}
}
pub fn state_json(&self, available: bool) -> Value {
let Some(fetched) = &self.fetched else {
return json!({ "available": available, "active": self.active, "flat": self.flat, "loading": false, "pendingEdits": self.patches.len(), "lastEdit": edit_json(&self.last_edit) });
};
let answer = fetched.slot.borrow();
let (loading, error, server) = match answer.as_ref() {
None => (true, None, None),
Some(Err(e)) => (false, Some(e.clone()), None),
Some(Ok(bom)) => (false, None, Some(bom)),
};
let lines: Vec<Value> = server
.map(|bom| {
bom.lines
.iter()
.map(|l| {
json!({
"level": l.level, "position": l.position, "partId": l.part_id, "number": l.number,
"revisionId": l.revision_id, "revision": l.revision_label, "state": l.state,
"quantity": l.quantity, "total": l.total, "attributes": l.attributes,
"mpn": l.mpn, "manufacturer": l.manufacturer, "supplier": l.supplier,
"currency": l.currency, "unitPrice": l.unit_price, "extended": l.extended,
})
})
.collect()
})
.unwrap_or_default();
let (document, refused) = match &fetched.document {
Ok(lines) => (
lines
.iter()
.map(|l| json!({ "level": l.level, "position": l.position, "partId": l.part_id, "revisionId": l.revision_id, "quantity": l.quantity, "total": l.total }))
.collect::<Vec<_>>(),
Vec::new(),
),
Err(refused) => (Vec::new(), refused.clone()),
};
let differences = match (&fetched.document, server) {
(Ok(doc), Some(bom)) if !fetched.key.2 => Some(bom::compare(doc, &bom.lines)),
_ => None,
};
json!({
"available": available, "active": self.active, "flat": fetched.key.2, "loading": loading, "error": error,
"number": server.map(|b| b.number.clone()), "revision": server.map(|b| b.revision_label.clone()),
"lines": lines,
"totals": server.map(|b| b.totals.iter().map(|t| json!({ "currency": t.currency, "total": t.total, "lines": t.lines })).collect::<Vec<_>>()).unwrap_or_default(),
"unpriced": server.map(|b| b.unpriced), "warnings": server.map(|b| b.warnings.clone()).unwrap_or_default(),
"document": document, "documentRefused": refused, "differences": differences,
"pendingEdits": self.patches.len(), "lastEdit": edit_json(&self.last_edit),
})
}
}
fn edit_json(edit: &Option<Result<String, String>>) -> Value {
match edit {
None => Value::Null,
Some(Ok(text)) => json!({ "ok": true, "text": text }),
Some(Err(text)) => json!({ "ok": false, "text": text }),
}
}
fn number(value: f64) -> String {
if value.fract() == 0.0 && value.abs() < 1e15 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
#[derive(Default)]
pub struct WhereUsedSection {
part: Option<String>,
slot: Option<Slot<Result<bom::WhereUsed, String>>>,
}
impl WhereUsedSection {
pub fn busy(&self) -> bool {
self.slot.as_ref().is_some_and(|s| s.borrow().is_none())
}
fn fetch(&mut self, client: &Rc<PlmClient>, part: &str) {
let slot: Slot<Result<bom::WhereUsed, String>> = Rc::default();
let (into, client, id) = (slot.clone(), client.clone(), part.to_string());
spawn(async move {
let got = bom::where_used(&client, &id, None).await.map_err(|e| e.to_string());
*into.borrow_mut() = Some(got);
});
self.part = Some(part.to_string());
self.slot = Some(slot);
}
pub fn draw(&mut self, ui: &mut egui::Ui, client: &Rc<PlmClient>, part: &str, put: &mut dyn FnMut(&str, egui::Rect)) {
if self.part.as_deref() != Some(part) {
self.fetch(client, part);
}
let refresh = ui.button("Refresh").on_hover_text("Ask the PLM again");
put("refresh", refresh.rect);
if refresh.clicked() {
self.fetch(client, part);
}
let Some(slot) = &self.slot else { return };
match slot.borrow().as_ref() {
None => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Asking the PLM where this part is used…");
});
ui.ctx().request_repaint();
}
Some(Err(error)) => {
ui.label(egui::RichText::new(error).color(crate::panels::assembly_components::OUTDATED_AMBER));
}
Some(Ok(used)) => {
let shown = ui.scope(|ui| bom::show_where_used(ui, used)).response;
put("lines", shown.rect);
}
}
}
pub fn state_json(&self) -> Value {
let Some(slot) = &self.slot else { return Value::Null };
let answer = slot.borrow();
match answer.as_ref() {
None => json!({ "part": self.part, "loading": true }),
Some(Err(e)) => json!({ "part": self.part, "loading": false, "error": e }),
Some(Ok(used)) => json!({
"part": self.part, "loading": false, "number": used.number,
"lines": used.lines.iter().map(|l| json!({
"level": l.level, "partId": l.part_id, "number": l.number, "revision": l.revision_label,
"state": l.state, "current": l.current, "uses": l.uses_revision, "quantity": l.quantity, "top": l.top,
})).collect::<Vec<_>>(),
}),
}
}
}