Missing Field Handling

This commit is contained in:
2026-05-01 13:54:05 -05:00
parent 5a6491fa51
commit 09447be473
9 changed files with 252 additions and 105 deletions

View File

@@ -431,7 +431,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements; CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = YK2DB9NT3S; DEVELOPMENT_TEAM = YK2DB9NT3S;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -469,7 +469,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements; CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = YK2DB9NT3S; DEVELOPMENT_TEAM = YK2DB9NT3S;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;

View File

@@ -7,7 +7,7 @@
<key>LabWise.xcscheme_^#shared#^_</key> <key>LabWise.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@@ -66,19 +66,21 @@ final class AddChemicalViewModel {
} }
/// Edit mode all fields pre-populated from the existing chemical. /// 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) { init(editing chemical: Chemical) {
self.editingID = chemical.id self.editingID = chemical.id
chemicalName = chemical.chemicalName chemicalName = chemical.chemicalName ?? ""
casNumber = chemical.casNumber casNumber = chemical.casNumber ?? ""
physicalState = chemical.physicalState physicalState = chemical.physicalState ?? ""
storageDevice = chemical.storageDevice storageDevice = chemical.storageDevice ?? "Glass Bottle"
piFirstName = chemical.piFirstName piFirstName = chemical.piFirstName ?? ""
bldgCode = chemical.bldgCode bldgCode = chemical.bldgCode ?? ""
lab = chemical.lab lab = chemical.lab ?? ""
storageLocation = chemical.storageLocation storageLocation = chemical.storageLocation ?? ""
numberOfContainers = chemical.numberOfContainers numberOfContainers = chemical.numberOfContainers ?? ""
amountPerContainer = chemical.amountPerContainer amountPerContainer = chemical.amountPerContainer ?? ""
unitOfMeasure = chemical.unitOfMeasure unitOfMeasure = chemical.unitOfMeasure ?? ""
chemicalFormula = chemical.chemicalFormula ?? "" chemicalFormula = chemical.chemicalFormula ?? ""
molecularWeight = chemical.molecularWeight ?? "" molecularWeight = chemical.molecularWeight ?? ""
concentration = chemical.concentration ?? "" concentration = chemical.concentration ?? ""

View File

@@ -57,28 +57,44 @@ struct ChemicalDetailView: View {
var body: some View { var body: some View {
Form { Form {
Section("Identity") { let missing = chemical.missingFields
LabeledContent("Name", value: chemical.chemicalName) if !missing.isEmpty {
LabeledContent("CAS Number", value: chemical.casNumber) Section {
if let formula = chemical.chemicalFormula { VStack(alignment: .leading, spacing: 6) {
LabeledContent("Formula", value: formula) 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)
} }
if let mw = chemical.molecularWeight {
LabeledContent("Molecular Weight", value: mw)
} }
LabeledContent("Physical State", value: chemical.physicalState.capitalized) Text("Tap Edit to fill them in.")
if let conc = chemical.concentration { .font(.caption2)
LabeledContent("Concentration", value: conc) .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") { Section("Storage") {
LabeledContent("Location", value: chemical.storageLocation) row("Storage Location", chemical.storageLocation, required: true)
LabeledContent("Device", value: chemical.storageDevice) row("Storage Device", chemical.storageDevice, required: true)
LabeledContent("Building", value: chemical.bldgCode) row("Building Code", chemical.bldgCode, required: true)
LabeledContent("Lab", value: chemical.lab) row("Lab", chemical.lab, required: true)
LabeledContent("Containers", value: chemical.numberOfContainers) row("# of Containers", chemical.numberOfContainers, required: true)
LabeledContent("Amount / Container", value: "\(chemical.amountPerContainer) \(chemical.unitOfMeasure)") amountRow
if let pct = chemical.percentageFull { if let pct = chemical.percentageFull {
LabeledContent("% Full") { LabeledContent("% Full") {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -92,25 +108,15 @@ struct ChemicalDetailView: View {
} }
Section("Vendor") { Section("Vendor") {
LabeledContent("PI", value: chemical.piFirstName) row("PI First Name", chemical.piFirstName, required: true)
if let vendor = chemical.vendor { row("Vendor", chemical.vendor)
LabeledContent("Vendor", value: vendor) row("Catalog #", chemical.catalogNumber)
} row("Lot #", chemical.lotNumber)
if let catalog = chemical.catalogNumber {
LabeledContent("Catalog #", value: catalog)
}
if let lot = chemical.lotNumber {
LabeledContent("Lot #", value: lot)
}
if let exp = chemical.expirationDate, !exp.isEmpty { if let exp = chemical.expirationDate, !exp.isEmpty {
LabeledContent("Expiration", value: formatDate(exp)) LabeledContent("Expiration", value: formatDate(exp))
} }
if let barcode = chemical.barcode { row("Barcode", chemical.barcode)
LabeledContent("Barcode", value: barcode) row("Contact", chemical.contact)
}
if let contact = chemical.contact {
LabeledContent("Contact", value: contact)
}
} }
if let comments = chemical.comments, !comments.isEmpty { if let comments = chemical.comments, !comments.isEmpty {
@@ -129,7 +135,7 @@ struct ChemicalDetailView: View {
} }
} }
} }
.navigationTitle(chemical.chemicalName) .navigationTitle(chemical.displayName)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -157,4 +163,46 @@ struct ChemicalDetailView: View {
chemical = match chemical = match
} }
} }
/// Combines "<amount> <unit>" 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 `<Field>` 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))
}
}
} }

View File

@@ -9,14 +9,23 @@ struct ChemicalRowView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text(chemical.chemicalName) Text(chemical.displayName)
.font(.headline) .font(.headline)
Spacer() Spacer()
PhysicalStateBadge(state: chemical.physicalState) if let state = chemical.physicalState, !state.isEmpty {
PhysicalStateBadge(state: state)
} }
Text("CAS: \(chemical.casNumber)") }
HStack(spacing: 6) {
if let cas = chemical.casNumber, !cas.isEmpty {
Text("CAS: \(cas)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
if chemical.isMissingKeyInfo {
MissingInfoBadge()
}
}
if let pct = chemical.percentageFull { if let pct = chemical.percentageFull {
PercentageBar(value: pct / 100) PercentageBar(value: pct / 100)
.frame(height: 4) .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 // MARK: - Percentage bar
struct PercentageBar: View { struct PercentageBar: View {

View File

@@ -215,13 +215,13 @@ private struct SafetyAlertsSection: View {
VStack(spacing: 8) { VStack(spacing: 8) {
ForEach(expired) { chem in ForEach(expired) { chem in
AlertRow( 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 severity: .critical
) )
} }
ForEach(lowStockAlerts) { chem in ForEach(lowStockAlerts) { chem in
AlertRow( 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 severity: .warning
) )
} }
@@ -275,8 +275,8 @@ private struct LowStockSection: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(viewModel.lowStock.prefix(3)) { chem in ForEach(viewModel.lowStock.prefix(3)) { chem in
ChemAlertRow( ChemAlertRow(
name: chem.chemicalName, name: chem.displayName,
subtitle: "\(chem.lab) · \(chem.storageLocation)", subtitle: "\(chem.lab ?? "") · \(chem.storageLocation ?? "")",
badge: "\(Int(chem.percentageFull ?? 0))% full", badge: "\(Int(chem.percentageFull ?? 0))% full",
badgeColor: .orange badgeColor: .orange
) )
@@ -304,8 +304,8 @@ private struct ExpiringSection: View {
ForEach(viewModel.expiringSoon.prefix(4)) { chem in ForEach(viewModel.expiringSoon.prefix(4)) { chem in
let days = viewModel.daysUntil(chem.expirationDate ?? "") let days = viewModel.daysUntil(chem.expirationDate ?? "")
ChemAlertRow( ChemAlertRow(
name: chem.chemicalName, name: chem.displayName,
subtitle: "\(chem.lab) · \(chem.storageLocation)", subtitle: "\(chem.lab ?? "") · \(chem.storageLocation ?? "")",
badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d", badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d",
badgeColor: days <= 0 ? .red : .orange badgeColor: days <= 0 ? .red : .orange
) )
@@ -336,7 +336,7 @@ private struct RecentActivitySection: View {
.foregroundStyle(Color(.brandPrimary)) .foregroundStyle(Color(.brandPrimary))
.font(.title3) .font(.title3)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Added \(chem.chemicalName) to inventory") Text("Added \(chem.displayName) to inventory")
.font(.subheadline) .font(.subheadline)
.lineLimit(1) .lineLimit(1)
Text(viewModel.relativeTime(chem.createdAt)) Text(viewModel.relativeTime(chem.createdAt))

View File

@@ -7,6 +7,10 @@ final class InventoryViewModel {
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var missingInfoCount: Int {
chemicals.filter(\.isMissingKeyInfo).count
}
private let client = ChemicalsClient() private let client = ChemicalsClient()
func loadChemicals() async { func loadChemicals() async {
@@ -73,6 +77,12 @@ struct InventoryView: View {
} }
} else { } else {
List { List {
if viewModel.missingInfoCount > 0 && !isSelectMode {
Section {
MissingInfoBanner(count: viewModel.missingInfoCount)
}
.listRowBackground(Color.orange.opacity(0.08))
}
ForEach(viewModel.chemicals) { chemical in ForEach(viewModel.chemicals) { chemical in
if isSelectMode { if isSelectMode {
Button { 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)
}
}

View File

@@ -12,17 +12,17 @@ struct SpreadsheetView: View {
} }
private static let columnDefs: [ColumnDef] = [ private static let columnDefs: [ColumnDef] = [
ColumnDef(label: "PI First Name", width: 130) { $0.piFirstName }, ColumnDef(label: "PI First Name", width: 130) { $0.piFirstName ?? "" },
ColumnDef(label: "Physical State", width: 120) { $0.physicalState }, ColumnDef(label: "Physical State", width: 120) { $0.physicalState ?? "" },
ColumnDef(label: "Chemical Name", width: 180) { $0.chemicalName }, ColumnDef(label: "Chemical Name", width: 180) { $0.chemicalName ?? "" },
ColumnDef(label: "Bldg Code", width: 100) { $0.bldgCode }, ColumnDef(label: "Bldg Code", width: 100) { $0.bldgCode ?? "" },
ColumnDef(label: "LAB", width: 80) { $0.lab }, ColumnDef(label: "LAB", width: 80) { $0.lab ?? "" },
ColumnDef(label: "Storage Location", width: 145) { $0.storageLocation }, ColumnDef(label: "Storage Location", width: 145) { $0.storageLocation ?? "" },
ColumnDef(label: "Storage Device", width: 140) { $0.storageDevice }, ColumnDef(label: "Storage Device", width: 140) { $0.storageDevice ?? "" },
ColumnDef(label: "# of Containers", width: 120) { $0.numberOfContainers }, ColumnDef(label: "# of Containers", width: 120) { $0.numberOfContainers ?? "" },
ColumnDef(label: "Amount / Container", width: 150) { $0.amountPerContainer }, ColumnDef(label: "Amount / Container", width: 150) { $0.amountPerContainer ?? "" },
ColumnDef(label: "Unit of Measure", width: 130) { $0.unitOfMeasure }, ColumnDef(label: "Unit of Measure", width: 130) { $0.unitOfMeasure ?? "" },
ColumnDef(label: "CAS #", width: 115) { $0.casNumber }, ColumnDef(label: "CAS #", width: 115) { $0.casNumber ?? "" },
ColumnDef(label: "Chemical Formula", width: 140) { $0.chemicalFormula ?? "" }, ColumnDef(label: "Chemical Formula", width: 140) { $0.chemicalFormula ?? "" },
ColumnDef(label: "Molecular Weight", width: 130) { $0.molecularWeight ?? "" }, ColumnDef(label: "Molecular Weight", width: 130) { $0.molecularWeight ?? "" },
ColumnDef(label: "Vendor", width: 130) { $0.vendor ?? "" }, ColumnDef(label: "Vendor", width: 130) { $0.vendor ?? "" },

View File

@@ -2,17 +2,20 @@ import Foundation
public struct Chemical: Identifiable, Sendable, Encodable { public struct Chemical: Identifiable, Sendable, Encodable {
public let id: String public let id: String
public var piFirstName: String // These eleven fields are nullable on the server (rows imported from spreadsheets
public var physicalState: String // can leave any of them blank). Treat them as optional everywhere on read; the
public var chemicalName: String // Add/Edit form still validates them as required on write.
public var bldgCode: String public var piFirstName: String?
public var lab: String public var physicalState: String?
public var storageLocation: String public var chemicalName: String?
public var storageDevice: String public var bldgCode: String?
public var numberOfContainers: String public var lab: String?
public var amountPerContainer: String public var storageLocation: String?
public var unitOfMeasure: String public var storageDevice: String?
public var casNumber: String public var numberOfContainers: String?
public var amountPerContainer: String?
public var unitOfMeasure: String?
public var casNumber: String?
public var chemicalFormula: String? public var chemicalFormula: String?
public var molecularWeight: String? public var molecularWeight: String?
public var vendor: String? public var vendor: String?
@@ -31,17 +34,17 @@ public struct Chemical: Identifiable, Sendable, Encodable {
public init( public init(
id: String = "", id: String = "",
piFirstName: String = "", piFirstName: String? = nil,
physicalState: String = "", physicalState: String? = nil,
chemicalName: String = "", chemicalName: String? = nil,
bldgCode: String = "", bldgCode: String? = nil,
lab: String = "", lab: String? = nil,
storageLocation: String = "", storageLocation: String? = nil,
storageDevice: String = "", storageDevice: String? = nil,
numberOfContainers: String = "", numberOfContainers: String? = nil,
amountPerContainer: String = "", amountPerContainer: String? = nil,
unitOfMeasure: String = "", unitOfMeasure: String? = nil,
casNumber: String = "", casNumber: String? = nil,
chemicalFormula: String? = nil, chemicalFormula: String? = nil,
molecularWeight: String? = nil, molecularWeight: String? = nil,
vendor: String? = nil, vendor: String? = nil,
@@ -92,17 +95,17 @@ extension Chemical: Decodable {
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id) id = try c.decode(String.self, forKey: .id)
piFirstName = try c.decode(String.self, forKey: .piFirstName) piFirstName = try c.decodeIfPresent(String.self, forKey: .piFirstName)
physicalState = try c.decode(String.self, forKey: .physicalState) physicalState = try c.decodeIfPresent(String.self, forKey: .physicalState)
chemicalName = try c.decode(String.self, forKey: .chemicalName) chemicalName = try c.decodeIfPresent(String.self, forKey: .chemicalName)
bldgCode = try c.decode(String.self, forKey: .bldgCode) bldgCode = try c.decodeIfPresent(String.self, forKey: .bldgCode)
lab = try c.decode(String.self, forKey: .lab) lab = try c.decodeIfPresent(String.self, forKey: .lab)
storageLocation = try c.decode(String.self, forKey: .storageLocation) storageLocation = try c.decodeIfPresent(String.self, forKey: .storageLocation)
storageDevice = try c.decode(String.self, forKey: .storageDevice) storageDevice = try c.decodeIfPresent(String.self, forKey: .storageDevice)
numberOfContainers = try c.decode(String.self, forKey: .numberOfContainers) numberOfContainers = try c.decodeIfPresent(String.self, forKey: .numberOfContainers)
amountPerContainer = try c.decode(String.self, forKey: .amountPerContainer) amountPerContainer = try c.decodeIfPresent(String.self, forKey: .amountPerContainer)
unitOfMeasure = try c.decode(String.self, forKey: .unitOfMeasure) unitOfMeasure = try c.decodeIfPresent(String.self, forKey: .unitOfMeasure)
casNumber = try c.decode(String.self, forKey: .casNumber) casNumber = try c.decodeIfPresent(String.self, forKey: .casNumber)
chemicalFormula = try c.decodeIfPresent(String.self, forKey: .chemicalFormula) chemicalFormula = try c.decodeIfPresent(String.self, forKey: .chemicalFormula)
molecularWeight = try c.decodeIfPresent(String.self, forKey: .molecularWeight) molecularWeight = try c.decodeIfPresent(String.self, forKey: .molecularWeight)
vendor = try c.decodeIfPresent(String.self, forKey: .vendor) 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 struct ChemicalCreateBody: Codable, Sendable {
public var piFirstName: String public var piFirstName: String
public var physicalState: String public var physicalState: String