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