diff --git a/LabWise.xcodeproj/project.pbxproj b/LabWise.xcodeproj/project.pbxproj
index d3fedca..6ee0adf 100644
--- a/LabWise.xcodeproj/project.pbxproj
+++ b/LabWise.xcodeproj/project.pbxproj
@@ -431,7 +431,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = YK2DB9NT3S;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -469,7 +469,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = YK2DB9NT3S;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
diff --git a/LabWise.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist b/LabWise.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist
index a9c03c8..54c14a0 100644
--- a/LabWise.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/LabWise.xcodeproj/xcuserdata/adipu.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,7 @@
LabWise.xcscheme_^#shared#^_
orderHint
- 1
+ 0
diff --git a/LabWise/AddChemicalView.swift b/LabWise/AddChemicalView.swift
index ef54e89..dd5e59b 100644
--- a/LabWise/AddChemicalView.swift
+++ b/LabWise/AddChemicalView.swift
@@ -66,19 +66,21 @@ final class AddChemicalViewModel {
}
/// Edit mode — all fields pre-populated from the existing chemical.
+ /// Required fields may be nil on rows that were imported with missing data;
+ /// the form will show them blank and require the user to fill them in to save.
init(editing chemical: Chemical) {
self.editingID = chemical.id
- chemicalName = chemical.chemicalName
- casNumber = chemical.casNumber
- physicalState = chemical.physicalState
- storageDevice = chemical.storageDevice
- piFirstName = chemical.piFirstName
- bldgCode = chemical.bldgCode
- lab = chemical.lab
- storageLocation = chemical.storageLocation
- numberOfContainers = chemical.numberOfContainers
- amountPerContainer = chemical.amountPerContainer
- unitOfMeasure = chemical.unitOfMeasure
+ chemicalName = chemical.chemicalName ?? ""
+ casNumber = chemical.casNumber ?? ""
+ physicalState = chemical.physicalState ?? ""
+ storageDevice = chemical.storageDevice ?? "Glass Bottle"
+ piFirstName = chemical.piFirstName ?? ""
+ bldgCode = chemical.bldgCode ?? ""
+ lab = chemical.lab ?? ""
+ storageLocation = chemical.storageLocation ?? ""
+ numberOfContainers = chemical.numberOfContainers ?? ""
+ amountPerContainer = chemical.amountPerContainer ?? ""
+ unitOfMeasure = chemical.unitOfMeasure ?? ""
chemicalFormula = chemical.chemicalFormula ?? ""
molecularWeight = chemical.molecularWeight ?? ""
concentration = chemical.concentration ?? ""
diff --git a/LabWise/ChemicalDetailView.swift b/LabWise/ChemicalDetailView.swift
index 05c6142..e963cf0 100644
--- a/LabWise/ChemicalDetailView.swift
+++ b/LabWise/ChemicalDetailView.swift
@@ -57,28 +57,44 @@ struct ChemicalDetailView: View {
var body: some View {
Form {
- Section("Identity") {
- LabeledContent("Name", value: chemical.chemicalName)
- LabeledContent("CAS Number", value: chemical.casNumber)
- if let formula = chemical.chemicalFormula {
- LabeledContent("Formula", value: formula)
- }
- if let mw = chemical.molecularWeight {
- LabeledContent("Molecular Weight", value: mw)
- }
- LabeledContent("Physical State", value: chemical.physicalState.capitalized)
- if let conc = chemical.concentration {
- LabeledContent("Concentration", value: conc)
+ let missing = chemical.missingFields
+ if !missing.isEmpty {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Missing \(missing.count) required \(missing.count == 1 ? "field" : "fields")")
+ .font(.subheadline.weight(.semibold))
+ Text(missing.joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Text("Tap Edit to fill them in.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
}
}
+ Section("Identity") {
+ row("Chemical Name", chemical.chemicalName, required: true)
+ row("CAS #", chemical.casNumber, required: true)
+ row("Formula", chemical.chemicalFormula)
+ row("Molecular Weight", chemical.molecularWeight)
+ row("Physical State", chemical.physicalState?.capitalized, required: true)
+ row("Concentration", chemical.concentration)
+ }
+
Section("Storage") {
- LabeledContent("Location", value: chemical.storageLocation)
- LabeledContent("Device", value: chemical.storageDevice)
- LabeledContent("Building", value: chemical.bldgCode)
- LabeledContent("Lab", value: chemical.lab)
- LabeledContent("Containers", value: chemical.numberOfContainers)
- LabeledContent("Amount / Container", value: "\(chemical.amountPerContainer) \(chemical.unitOfMeasure)")
+ row("Storage Location", chemical.storageLocation, required: true)
+ row("Storage Device", chemical.storageDevice, required: true)
+ row("Building Code", chemical.bldgCode, required: true)
+ row("Lab", chemical.lab, required: true)
+ row("# of Containers", chemical.numberOfContainers, required: true)
+ amountRow
if let pct = chemical.percentageFull {
LabeledContent("% Full") {
HStack(spacing: 8) {
@@ -92,25 +108,15 @@ struct ChemicalDetailView: View {
}
Section("Vendor") {
- LabeledContent("PI", value: chemical.piFirstName)
- if let vendor = chemical.vendor {
- LabeledContent("Vendor", value: vendor)
- }
- if let catalog = chemical.catalogNumber {
- LabeledContent("Catalog #", value: catalog)
- }
- if let lot = chemical.lotNumber {
- LabeledContent("Lot #", value: lot)
- }
+ row("PI First Name", chemical.piFirstName, required: true)
+ row("Vendor", chemical.vendor)
+ row("Catalog #", chemical.catalogNumber)
+ row("Lot #", chemical.lotNumber)
if let exp = chemical.expirationDate, !exp.isEmpty {
LabeledContent("Expiration", value: formatDate(exp))
}
- if let barcode = chemical.barcode {
- LabeledContent("Barcode", value: barcode)
- }
- if let contact = chemical.contact {
- LabeledContent("Contact", value: contact)
- }
+ row("Barcode", chemical.barcode)
+ row("Contact", chemical.contact)
}
if let comments = chemical.comments, !comments.isEmpty {
@@ -129,7 +135,7 @@ struct ChemicalDetailView: View {
}
}
}
- .navigationTitle(chemical.chemicalName)
+ .navigationTitle(chemical.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -157,4 +163,46 @@ struct ChemicalDetailView: View {
chemical = match
}
}
+
+ /// Combines " " — both required, so we show a Missing
+ /// placeholder if either side is empty. Yellow-tinted when incomplete.
+ @ViewBuilder
+ private var amountRow: some View {
+ let amt = (chemical.amountPerContainer ?? "").trimmingCharacters(in: .whitespaces)
+ let unit = (chemical.unitOfMeasure ?? "").trimmingCharacters(in: .whitespaces)
+ if amt.isEmpty || unit.isEmpty {
+ let parts: [String] = [
+ amt.isEmpty ? "Amount" : nil,
+ unit.isEmpty ? "Unit" : nil,
+ ].compactMap { $0 }
+ LabeledContent("Amount / Container") {
+ Text("Missing \(parts.joined(separator: " & "))")
+ .italic()
+ .foregroundStyle(.orange)
+ }
+ .listRowBackground(Color.orange.opacity(0.12))
+ } else {
+ LabeledContent("Amount / Container", value: "\(amt) \(unit)")
+ }
+ }
+
+ /// Render a labelled row.
+ /// - Optional fields are hidden when empty (matches the web `` helper).
+ /// - Required fields always render: present value normally, missing value
+ /// with a yellow row background and an italic "Missing" placeholder so
+ /// the user can see exactly which field needs attention and where it lives.
+ @ViewBuilder
+ private func row(_ label: String, _ value: String?, required: Bool = false) -> some View {
+ let trimmed = (value ?? "").trimmingCharacters(in: .whitespaces)
+ if !trimmed.isEmpty {
+ LabeledContent(label, value: trimmed)
+ } else if required {
+ LabeledContent(label) {
+ Text("Missing")
+ .italic()
+ .foregroundStyle(.orange)
+ }
+ .listRowBackground(Color.orange.opacity(0.12))
+ }
+ }
}
diff --git a/LabWise/ChemicalsListView.swift b/LabWise/ChemicalsListView.swift
index e2b3b81..7d40ef7 100644
--- a/LabWise/ChemicalsListView.swift
+++ b/LabWise/ChemicalsListView.swift
@@ -9,14 +9,23 @@ struct ChemicalRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
- Text(chemical.chemicalName)
+ Text(chemical.displayName)
.font(.headline)
Spacer()
- PhysicalStateBadge(state: chemical.physicalState)
+ if let state = chemical.physicalState, !state.isEmpty {
+ PhysicalStateBadge(state: state)
+ }
+ }
+ HStack(spacing: 6) {
+ if let cas = chemical.casNumber, !cas.isEmpty {
+ Text("CAS: \(cas)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if chemical.isMissingKeyInfo {
+ MissingInfoBadge()
+ }
}
- Text("CAS: \(chemical.casNumber)")
- .font(.caption)
- .foregroundStyle(.secondary)
if let pct = chemical.percentageFull {
PercentageBar(value: pct / 100)
.frame(height: 4)
@@ -52,6 +61,25 @@ struct PhysicalStateBadge: View {
}
}
+// MARK: - Missing info badge
+
+/// Mirrors the web "Missing info" pill — amber, with a warning glyph.
+struct MissingInfoBadge: View {
+ var body: some View {
+ HStack(spacing: 3) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.caption2)
+ Text("Missing info")
+ .font(.caption2.weight(.semibold))
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.orange.opacity(0.15))
+ .foregroundStyle(Color.orange)
+ .clipShape(Capsule())
+ }
+}
+
// MARK: - Percentage bar
struct PercentageBar: View {
diff --git a/LabWise/DashboardView.swift b/LabWise/DashboardView.swift
index b34f4b0..142822c 100644
--- a/LabWise/DashboardView.swift
+++ b/LabWise/DashboardView.swift
@@ -215,13 +215,13 @@ private struct SafetyAlertsSection: View {
VStack(spacing: 8) {
ForEach(expired) { chem in
AlertRow(
- message: "\(chem.chemicalName) expired \(-viewModel.daysUntil(chem.expirationDate ?? ""))d ago — verify disposal",
+ message: "\(chem.displayName) expired \(-viewModel.daysUntil(chem.expirationDate ?? ""))d ago — verify disposal",
severity: .critical
)
}
ForEach(lowStockAlerts) { chem in
AlertRow(
- message: "\(chem.chemicalName) is \(Int(chem.percentageFull ?? 0))% full — consider reordering",
+ message: "\(chem.displayName) is \(Int(chem.percentageFull ?? 0))% full — consider reordering",
severity: .warning
)
}
@@ -275,8 +275,8 @@ private struct LowStockSection: View {
VStack(spacing: 0) {
ForEach(viewModel.lowStock.prefix(3)) { chem in
ChemAlertRow(
- name: chem.chemicalName,
- subtitle: "\(chem.lab) · \(chem.storageLocation)",
+ name: chem.displayName,
+ subtitle: "\(chem.lab ?? "—") · \(chem.storageLocation ?? "—")",
badge: "\(Int(chem.percentageFull ?? 0))% full",
badgeColor: .orange
)
@@ -304,8 +304,8 @@ private struct ExpiringSection: View {
ForEach(viewModel.expiringSoon.prefix(4)) { chem in
let days = viewModel.daysUntil(chem.expirationDate ?? "")
ChemAlertRow(
- name: chem.chemicalName,
- subtitle: "\(chem.lab) · \(chem.storageLocation)",
+ name: chem.displayName,
+ subtitle: "\(chem.lab ?? "—") · \(chem.storageLocation ?? "—")",
badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d",
badgeColor: days <= 0 ? .red : .orange
)
@@ -336,7 +336,7 @@ private struct RecentActivitySection: View {
.foregroundStyle(Color(.brandPrimary))
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
- Text("Added \(chem.chemicalName) to inventory")
+ Text("Added \(chem.displayName) to inventory")
.font(.subheadline)
.lineLimit(1)
Text(viewModel.relativeTime(chem.createdAt))
diff --git a/LabWise/InventoryView.swift b/LabWise/InventoryView.swift
index 51ff921..b994712 100644
--- a/LabWise/InventoryView.swift
+++ b/LabWise/InventoryView.swift
@@ -7,6 +7,10 @@ final class InventoryViewModel {
var isLoading = false
var errorMessage: String?
+ var missingInfoCount: Int {
+ chemicals.filter(\.isMissingKeyInfo).count
+ }
+
private let client = ChemicalsClient()
func loadChemicals() async {
@@ -73,6 +77,12 @@ struct InventoryView: View {
}
} else {
List {
+ if viewModel.missingInfoCount > 0 && !isSelectMode {
+ Section {
+ MissingInfoBanner(count: viewModel.missingInfoCount)
+ }
+ .listRowBackground(Color.orange.opacity(0.08))
+ }
ForEach(viewModel.chemicals) { chemical in
if isSelectMode {
Button {
@@ -204,3 +214,27 @@ struct InventoryView: View {
}
}
}
+
+// MARK: - Missing-info banner
+
+/// Shown above the inventory list when one or more rows are missing required
+/// fields (typically the result of a web spreadsheet import). Mirrors the
+/// amber banner on the web inventory page.
+private struct MissingInfoBanner: View {
+ let count: Int
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Missing Key Information")
+ .font(.subheadline.weight(.semibold))
+ Text("\(count) \(count == 1 ? "item is" : "items are") missing required fields. Tap an item flagged with “Missing info” to see exactly which fields and edit them.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/LabWise/SpreadsheetView.swift b/LabWise/SpreadsheetView.swift
index 1574fe7..0a13e2b 100644
--- a/LabWise/SpreadsheetView.swift
+++ b/LabWise/SpreadsheetView.swift
@@ -12,17 +12,17 @@ struct SpreadsheetView: View {
}
private static let columnDefs: [ColumnDef] = [
- ColumnDef(label: "PI First Name", width: 130) { $0.piFirstName },
- ColumnDef(label: "Physical State", width: 120) { $0.physicalState },
- ColumnDef(label: "Chemical Name", width: 180) { $0.chemicalName },
- ColumnDef(label: "Bldg Code", width: 100) { $0.bldgCode },
- ColumnDef(label: "LAB", width: 80) { $0.lab },
- ColumnDef(label: "Storage Location", width: 145) { $0.storageLocation },
- ColumnDef(label: "Storage Device", width: 140) { $0.storageDevice },
- ColumnDef(label: "# of Containers", width: 120) { $0.numberOfContainers },
- ColumnDef(label: "Amount / Container", width: 150) { $0.amountPerContainer },
- ColumnDef(label: "Unit of Measure", width: 130) { $0.unitOfMeasure },
- ColumnDef(label: "CAS #", width: 115) { $0.casNumber },
+ ColumnDef(label: "PI First Name", width: 130) { $0.piFirstName ?? "" },
+ ColumnDef(label: "Physical State", width: 120) { $0.physicalState ?? "" },
+ ColumnDef(label: "Chemical Name", width: 180) { $0.chemicalName ?? "" },
+ ColumnDef(label: "Bldg Code", width: 100) { $0.bldgCode ?? "" },
+ ColumnDef(label: "LAB", width: 80) { $0.lab ?? "" },
+ ColumnDef(label: "Storage Location", width: 145) { $0.storageLocation ?? "" },
+ ColumnDef(label: "Storage Device", width: 140) { $0.storageDevice ?? "" },
+ ColumnDef(label: "# of Containers", width: 120) { $0.numberOfContainers ?? "" },
+ ColumnDef(label: "Amount / Container", width: 150) { $0.amountPerContainer ?? "" },
+ ColumnDef(label: "Unit of Measure", width: 130) { $0.unitOfMeasure ?? "" },
+ ColumnDef(label: "CAS #", width: 115) { $0.casNumber ?? "" },
ColumnDef(label: "Chemical Formula", width: 140) { $0.chemicalFormula ?? "" },
ColumnDef(label: "Molecular Weight", width: 130) { $0.molecularWeight ?? "" },
ColumnDef(label: "Vendor", width: 130) { $0.vendor ?? "" },
diff --git a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift
index 6008b7c..d63f971 100644
--- a/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift
+++ b/LabWiseKit/Sources/LabWiseKit/Models/Chemical.swift
@@ -2,17 +2,20 @@ import Foundation
public struct Chemical: Identifiable, Sendable, Encodable {
public let id: String
- public var piFirstName: String
- public var physicalState: String
- public var chemicalName: String
- public var bldgCode: String
- public var lab: String
- public var storageLocation: String
- public var storageDevice: String
- public var numberOfContainers: String
- public var amountPerContainer: String
- public var unitOfMeasure: String
- public var casNumber: String
+ // These eleven fields are nullable on the server (rows imported from spreadsheets
+ // can leave any of them blank). Treat them as optional everywhere on read; the
+ // Add/Edit form still validates them as required on write.
+ public var piFirstName: String?
+ public var physicalState: String?
+ public var chemicalName: String?
+ public var bldgCode: String?
+ public var lab: String?
+ public var storageLocation: String?
+ public var storageDevice: String?
+ public var numberOfContainers: String?
+ public var amountPerContainer: String?
+ public var unitOfMeasure: String?
+ public var casNumber: String?
public var chemicalFormula: String?
public var molecularWeight: String?
public var vendor: String?
@@ -31,17 +34,17 @@ public struct Chemical: Identifiable, Sendable, Encodable {
public init(
id: String = "",
- piFirstName: String = "",
- physicalState: String = "",
- chemicalName: String = "",
- bldgCode: String = "",
- lab: String = "",
- storageLocation: String = "",
- storageDevice: String = "",
- numberOfContainers: String = "",
- amountPerContainer: String = "",
- unitOfMeasure: String = "",
- casNumber: String = "",
+ piFirstName: String? = nil,
+ physicalState: String? = nil,
+ chemicalName: String? = nil,
+ bldgCode: String? = nil,
+ lab: String? = nil,
+ storageLocation: String? = nil,
+ storageDevice: String? = nil,
+ numberOfContainers: String? = nil,
+ amountPerContainer: String? = nil,
+ unitOfMeasure: String? = nil,
+ casNumber: String? = nil,
chemicalFormula: String? = nil,
molecularWeight: String? = nil,
vendor: String? = nil,
@@ -92,17 +95,17 @@ extension Chemical: Decodable {
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
- piFirstName = try c.decode(String.self, forKey: .piFirstName)
- physicalState = try c.decode(String.self, forKey: .physicalState)
- chemicalName = try c.decode(String.self, forKey: .chemicalName)
- bldgCode = try c.decode(String.self, forKey: .bldgCode)
- lab = try c.decode(String.self, forKey: .lab)
- storageLocation = try c.decode(String.self, forKey: .storageLocation)
- storageDevice = try c.decode(String.self, forKey: .storageDevice)
- numberOfContainers = try c.decode(String.self, forKey: .numberOfContainers)
- amountPerContainer = try c.decode(String.self, forKey: .amountPerContainer)
- unitOfMeasure = try c.decode(String.self, forKey: .unitOfMeasure)
- casNumber = try c.decode(String.self, forKey: .casNumber)
+ piFirstName = try c.decodeIfPresent(String.self, forKey: .piFirstName)
+ physicalState = try c.decodeIfPresent(String.self, forKey: .physicalState)
+ chemicalName = try c.decodeIfPresent(String.self, forKey: .chemicalName)
+ bldgCode = try c.decodeIfPresent(String.self, forKey: .bldgCode)
+ lab = try c.decodeIfPresent(String.self, forKey: .lab)
+ storageLocation = try c.decodeIfPresent(String.self, forKey: .storageLocation)
+ storageDevice = try c.decodeIfPresent(String.self, forKey: .storageDevice)
+ numberOfContainers = try c.decodeIfPresent(String.self, forKey: .numberOfContainers)
+ amountPerContainer = try c.decodeIfPresent(String.self, forKey: .amountPerContainer)
+ unitOfMeasure = try c.decodeIfPresent(String.self, forKey: .unitOfMeasure)
+ casNumber = try c.decodeIfPresent(String.self, forKey: .casNumber)
chemicalFormula = try c.decodeIfPresent(String.self, forKey: .chemicalFormula)
molecularWeight = try c.decodeIfPresent(String.self, forKey: .molecularWeight)
vendor = try c.decodeIfPresent(String.self, forKey: .vendor)
@@ -145,6 +148,38 @@ extension Chemical: Decodable {
}
}
+/// The eleven fields the web app treats as "key information." A row missing any
+/// of these is flagged with a Missing-info badge in the inventory list.
+public extension Chemical {
+ /// Names of the required fields whose value is missing or whitespace-only.
+ /// Order matches the visual order in the iOS Add/Edit form so the
+ /// "Missing: …" banner reads top-to-bottom in the same order the user fills.
+ var missingFields: [String] {
+ func empty(_ s: String?) -> Bool { (s ?? "").trimmingCharacters(in: .whitespaces).isEmpty }
+ var out: [String] = []
+ if empty(chemicalName) { out.append("Chemical Name") }
+ if empty(casNumber) { out.append("CAS #") }
+ if empty(physicalState) { out.append("Physical State") }
+ if empty(storageDevice) { out.append("Storage Device") }
+ if empty(piFirstName) { out.append("PI First Name") }
+ if empty(bldgCode) { out.append("Building Code") }
+ if empty(lab) { out.append("Lab") }
+ if empty(storageLocation) { out.append("Storage Location") }
+ if empty(numberOfContainers) { out.append("# of Containers") }
+ if empty(amountPerContainer) { out.append("Amount / Container") }
+ if empty(unitOfMeasure) { out.append("Unit of Measure") }
+ return out
+ }
+
+ var isMissingKeyInfo: Bool { !missingFields.isEmpty }
+
+ /// Best-effort display name; falls back when `chemicalName` is missing.
+ var displayName: String {
+ let trimmed = (chemicalName ?? "").trimmingCharacters(in: .whitespaces)
+ return trimmed.isEmpty ? "Unnamed chemical" : trimmed
+ }
+}
+
public struct ChemicalCreateBody: Codable, Sendable {
public var piFirstName: String
public var physicalState: String