Missing Field Handling
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? ""
|
||||||
|
|||||||
@@ -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")
|
||||||
if let mw = chemical.molecularWeight {
|
.foregroundStyle(.orange)
|
||||||
LabeledContent("Molecular Weight", value: mw)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
}
|
Text("Missing \(missing.count) required \(missing.count == 1 ? "field" : "fields")")
|
||||||
LabeledContent("Physical State", value: chemical.physicalState.capitalized)
|
.font(.subheadline.weight(.semibold))
|
||||||
if let conc = chemical.concentration {
|
Text(missing.joined(separator: ", "))
|
||||||
LabeledContent("Concentration", value: conc)
|
.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") {
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ?? "" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user