2026-03-20 02:30:15 -05:00
|
|
|
import SwiftUI
|
|
|
|
|
import LabWiseKit
|
|
|
|
|
|
|
|
|
|
// MARK: - View Model
|
|
|
|
|
|
|
|
|
|
@Observable
|
|
|
|
|
final class AddChemicalViewModel {
|
|
|
|
|
// Required fields
|
|
|
|
|
var chemicalName = ""
|
|
|
|
|
var casNumber = ""
|
|
|
|
|
var physicalState = ""
|
|
|
|
|
var storageDevice = "Glass Bottle"
|
|
|
|
|
var piFirstName = ""
|
|
|
|
|
var bldgCode = ""
|
|
|
|
|
var lab = ""
|
|
|
|
|
var storageLocation = ""
|
|
|
|
|
var numberOfContainers = "1"
|
|
|
|
|
var amountPerContainer = ""
|
|
|
|
|
var unitOfMeasure = ""
|
|
|
|
|
|
|
|
|
|
// Optional fields
|
|
|
|
|
var chemicalFormula = ""
|
|
|
|
|
var molecularWeight = ""
|
|
|
|
|
var concentration = ""
|
|
|
|
|
var percentageFull = ""
|
|
|
|
|
var vendor = ""
|
|
|
|
|
var catalogNumber = ""
|
|
|
|
|
var lotNumber = ""
|
|
|
|
|
var expirationDate = "" // ISO date string "YYYY-MM-DD", empty = none
|
|
|
|
|
var hasExpirationDate = false // drives the DatePicker toggle
|
|
|
|
|
var expirationDatePicker = Date()
|
|
|
|
|
var barcode = ""
|
|
|
|
|
var contact = ""
|
|
|
|
|
var comments = ""
|
|
|
|
|
|
|
|
|
|
var isSaving = false
|
|
|
|
|
var formError: String?
|
|
|
|
|
|
|
|
|
|
/// Non-nil when editing an existing chemical.
|
|
|
|
|
private let editingID: String?
|
|
|
|
|
private let chemicalsClient = ChemicalsClient()
|
|
|
|
|
private let profileClient = ProfileClient()
|
|
|
|
|
|
|
|
|
|
static let isoDateFormatter: DateFormatter = {
|
|
|
|
|
let f = DateFormatter()
|
|
|
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
|
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
static let physicalStates = ["Solid", "Liquid", "Gas"]
|
|
|
|
|
static let storageDevices = [
|
|
|
|
|
"Aerosol Can", "Ampule", "Bulked Item", "Fiber Box", "Gas Cylinder",
|
|
|
|
|
"Glass Bottle", "Metal Can", "Metal Drum", "Metal Open Drum",
|
|
|
|
|
"Pallet", "Plastic Bag", "Plastic Bottle", "Plastic Drum", "Plastic Open Drum"
|
|
|
|
|
]
|
|
|
|
|
static let unitsOfMeasure = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol", "Other"]
|
|
|
|
|
|
|
|
|
|
var isEditing: Bool { editingID != nil }
|
|
|
|
|
|
|
|
|
|
// MARK: Init
|
|
|
|
|
|
|
|
|
|
/// Add mode — fields start empty (profile-pre-filled later).
|
|
|
|
|
init() {
|
|
|
|
|
self.editingID = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Edit mode — all fields pre-populated from the existing chemical.
|
|
|
|
|
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
|
|
|
|
|
chemicalFormula = chemical.chemicalFormula ?? ""
|
|
|
|
|
molecularWeight = chemical.molecularWeight ?? ""
|
|
|
|
|
concentration = chemical.concentration ?? ""
|
|
|
|
|
percentageFull = chemical.percentageFull.map { String(Int($0)) } ?? ""
|
|
|
|
|
vendor = chemical.vendor ?? ""
|
|
|
|
|
catalogNumber = chemical.catalogNumber ?? ""
|
|
|
|
|
lotNumber = chemical.lotNumber ?? ""
|
|
|
|
|
if let exp = chemical.expirationDate, !exp.isEmpty,
|
|
|
|
|
let parsed = Self.isoDateFormatter.date(from: exp) {
|
|
|
|
|
expirationDate = exp
|
|
|
|
|
hasExpirationDate = true
|
|
|
|
|
expirationDatePicker = parsed
|
|
|
|
|
}
|
|
|
|
|
barcode = chemical.barcode ?? ""
|
|
|
|
|
contact = chemical.contact ?? ""
|
|
|
|
|
comments = chemical.comments ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Validation
|
|
|
|
|
|
|
|
|
|
var missingRequiredFields: [String] {
|
|
|
|
|
var missing: [String] = []
|
|
|
|
|
if chemicalName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Chemical Name") }
|
|
|
|
|
if casNumber.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("CAS #") }
|
|
|
|
|
if physicalState.isEmpty { missing.append("Physical State") }
|
|
|
|
|
if storageDevice.isEmpty { missing.append("Storage Device") }
|
|
|
|
|
if piFirstName.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("PI First Name") }
|
|
|
|
|
if bldgCode.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Building Code") }
|
|
|
|
|
if lab.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Lab") }
|
|
|
|
|
if storageLocation.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Storage Location") }
|
|
|
|
|
if numberOfContainers.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("# of Containers") }
|
|
|
|
|
if amountPerContainer.trimmingCharacters(in: .whitespaces).isEmpty { missing.append("Amount / Container") }
|
|
|
|
|
if unitOfMeasure.isEmpty { missing.append("Unit of Measure") }
|
|
|
|
|
return missing
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Load profile (add mode only)
|
|
|
|
|
|
|
|
|
|
func loadProfile() async {
|
|
|
|
|
guard !isEditing else { return }
|
|
|
|
|
if let profile = try? await profileClient.get() {
|
2026-04-10 22:20:12 -05:00
|
|
|
piFirstName = profile.piFirstName ?? ""
|
|
|
|
|
bldgCode = profile.bldgCode ?? ""
|
|
|
|
|
lab = profile.lab ?? ""
|
2026-03-20 02:30:15 -05:00
|
|
|
contact = profile.contact ?? ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Save (POST or PATCH)
|
|
|
|
|
|
|
|
|
|
func save() async -> Bool {
|
|
|
|
|
guard missingRequiredFields.isEmpty else {
|
|
|
|
|
formError = "Please fill in all required fields."
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
isSaving = true
|
|
|
|
|
formError = nil
|
|
|
|
|
defer { isSaving = false }
|
|
|
|
|
|
|
|
|
|
// Sync the date picker value into the string field before saving
|
|
|
|
|
if hasExpirationDate {
|
|
|
|
|
expirationDate = Self.isoDateFormatter.string(from: expirationDatePicker)
|
|
|
|
|
} else {
|
|
|
|
|
expirationDate = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let body = ChemicalCreateBody(
|
|
|
|
|
piFirstName: piFirstName,
|
|
|
|
|
physicalState: physicalState,
|
|
|
|
|
chemicalName: chemicalName,
|
|
|
|
|
bldgCode: bldgCode,
|
|
|
|
|
lab: lab,
|
|
|
|
|
storageLocation: storageLocation,
|
|
|
|
|
storageDevice: storageDevice,
|
|
|
|
|
numberOfContainers: numberOfContainers,
|
|
|
|
|
amountPerContainer: amountPerContainer,
|
|
|
|
|
unitOfMeasure: unitOfMeasure,
|
|
|
|
|
casNumber: casNumber,
|
|
|
|
|
chemicalFormula: chemicalFormula.isEmpty ? nil : chemicalFormula,
|
|
|
|
|
molecularWeight: molecularWeight.isEmpty ? nil : molecularWeight,
|
|
|
|
|
vendor: vendor.isEmpty ? nil : vendor,
|
|
|
|
|
catalogNumber: catalogNumber.isEmpty ? nil : catalogNumber,
|
|
|
|
|
lotNumber: lotNumber.isEmpty ? nil : lotNumber,
|
|
|
|
|
expirationDate: expirationDate.isEmpty ? nil : expirationDate,
|
|
|
|
|
concentration: concentration.isEmpty ? nil : concentration,
|
|
|
|
|
percentageFull: Double(percentageFull),
|
|
|
|
|
comments: comments.isEmpty ? nil : comments,
|
|
|
|
|
barcode: barcode.isEmpty ? nil : barcode,
|
|
|
|
|
contact: contact.isEmpty ? nil : contact
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
if let id = editingID {
|
|
|
|
|
_ = try await chemicalsClient.update(id: id, body: body)
|
|
|
|
|
} else {
|
|
|
|
|
_ = try await chemicalsClient.create(body)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
formError = isEditing ? "Failed to update chemical." : "Failed to save chemical."
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - View
|
|
|
|
|
|
|
|
|
|
struct AddChemicalView: View {
|
|
|
|
|
let onDismiss: (Bool) -> Void
|
|
|
|
|
|
|
|
|
|
@State private var viewModel: AddChemicalViewModel
|
|
|
|
|
@State private var showOptional: Bool
|
|
|
|
|
|
|
|
|
|
/// Add mode
|
|
|
|
|
init(onDismiss: @escaping (Bool) -> Void) {
|
|
|
|
|
self.onDismiss = onDismiss
|
|
|
|
|
self._viewModel = State(initialValue: AddChemicalViewModel())
|
|
|
|
|
self._showOptional = State(initialValue: false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Edit mode — optional fields auto-expanded, all values pre-filled
|
|
|
|
|
init(editing chemical: Chemical, onDismiss: @escaping (Bool) -> Void) {
|
|
|
|
|
self.onDismiss = onDismiss
|
|
|
|
|
self._viewModel = State(initialValue: AddChemicalViewModel(editing: chemical))
|
|
|
|
|
self._showOptional = State(initialValue: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
NavigationStack {
|
|
|
|
|
Form {
|
|
|
|
|
// MARK: Identity
|
|
|
|
|
Section("Identity") {
|
|
|
|
|
FormTextField("Chemical Name", text: $viewModel.chemicalName, required: true)
|
|
|
|
|
FormTextField("CAS #", text: $viewModel.casNumber, required: true, placeholder: "e.g. 67-56-1")
|
|
|
|
|
Picker("Physical State", selection: $viewModel.physicalState) {
|
|
|
|
|
Text("Select…").tag("")
|
|
|
|
|
ForEach(AddChemicalViewModel.physicalStates, id: \.self) { state in
|
|
|
|
|
Text(state).tag(state)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Picker("Storage Device", selection: $viewModel.storageDevice) {
|
|
|
|
|
ForEach(AddChemicalViewModel.storageDevices, id: \.self) { device in
|
|
|
|
|
Text(device).tag(device)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Location
|
|
|
|
|
Section("Location") {
|
|
|
|
|
FormTextField("PI First Name", text: $viewModel.piFirstName, required: true)
|
|
|
|
|
FormTextField("Building Code", text: $viewModel.bldgCode, required: true)
|
|
|
|
|
FormTextField("Lab", text: $viewModel.lab, required: true)
|
|
|
|
|
FormTextField("Storage Location", text: $viewModel.storageLocation, required: true, placeholder: "e.g. Cabinet A-3")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Quantity
|
|
|
|
|
Section("Quantity") {
|
|
|
|
|
FormTextField("# of Containers", text: $viewModel.numberOfContainers, required: true, keyboard: .numberPad)
|
|
|
|
|
FormTextField("Amount / Container", text: $viewModel.amountPerContainer, required: true, placeholder: "e.g. 500")
|
|
|
|
|
Picker("Unit of Measure", selection: $viewModel.unitOfMeasure) {
|
|
|
|
|
Text("Select…").tag("")
|
|
|
|
|
ForEach(AddChemicalViewModel.unitsOfMeasure, id: \.self) { unit in
|
|
|
|
|
Text(unit).tag(unit)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Optional toggle
|
|
|
|
|
Section {
|
|
|
|
|
Button {
|
|
|
|
|
withAnimation { showOptional.toggle() }
|
|
|
|
|
} label: {
|
|
|
|
|
HStack {
|
|
|
|
|
Text(showOptional ? "Hide optional fields" : "Show optional fields")
|
|
|
|
|
.foregroundStyle(Color(.brandPrimary))
|
|
|
|
|
Spacer()
|
|
|
|
|
Image(systemName: showOptional ? "chevron.up" : "chevron.down")
|
|
|
|
|
.foregroundStyle(Color(.brandPrimary))
|
|
|
|
|
.font(.caption)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Optional fields
|
|
|
|
|
if showOptional {
|
|
|
|
|
Section("Details") {
|
|
|
|
|
FormTextField("Chemical Formula", text: $viewModel.chemicalFormula, placeholder: "e.g. CH₃OH")
|
|
|
|
|
FormTextField("Molecular Weight", text: $viewModel.molecularWeight, placeholder: "g/mol")
|
|
|
|
|
FormTextField("Concentration", text: $viewModel.concentration, placeholder: "e.g. 99.8%")
|
|
|
|
|
FormTextField("% Full", text: $viewModel.percentageFull, keyboard: .decimalPad)
|
|
|
|
|
FormTextField("Vendor", text: $viewModel.vendor)
|
|
|
|
|
FormTextField("Catalog #", text: $viewModel.catalogNumber)
|
|
|
|
|
FormTextField("Lot #", text: $viewModel.lotNumber)
|
|
|
|
|
ExpirationDateRow(
|
|
|
|
|
hasDate: $viewModel.hasExpirationDate,
|
|
|
|
|
date: $viewModel.expirationDatePicker
|
|
|
|
|
)
|
|
|
|
|
FormTextField("Barcode", text: $viewModel.barcode)
|
|
|
|
|
FormTextField("Contact", text: $viewModel.contact)
|
|
|
|
|
FormTextField("Comments", text: $viewModel.comments)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Error + Save
|
|
|
|
|
Section {
|
|
|
|
|
if let error = viewModel.formError {
|
|
|
|
|
Text(error)
|
|
|
|
|
.foregroundStyle(.red)
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
}
|
|
|
|
|
Button {
|
|
|
|
|
Task {
|
|
|
|
|
let saved = await viewModel.save()
|
|
|
|
|
if saved { onDismiss(true) }
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
if viewModel.isSaving {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
} else {
|
|
|
|
|
Text(viewModel.isEditing ? "Save Changes" : "Save Chemical")
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.fontWeight(.semibold)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
|
.tint(Color(.brandPrimary))
|
|
|
|
|
.disabled(!viewModel.missingRequiredFields.isEmpty || viewModel.isSaving)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.navigationTitle(viewModel.isEditing ? "Edit Chemical" : "Add Chemical")
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
|
|
|
.toolbar {
|
|
|
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
|
|
|
Button("Cancel") { onDismiss(false) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.task {
|
|
|
|
|
await viewModel.loadProfile()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Expiration date picker row
|
|
|
|
|
|
|
|
|
|
private struct ExpirationDateRow: View {
|
|
|
|
|
@Binding var hasDate: Bool
|
|
|
|
|
@Binding var date: Date
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Toggle(isOn: $hasDate.animation()) {
|
|
|
|
|
Text("Expiration Date")
|
|
|
|
|
}
|
|
|
|
|
.tint(Color(.brandPrimary))
|
|
|
|
|
|
|
|
|
|
if hasDate {
|
|
|
|
|
DatePicker(
|
|
|
|
|
"Date",
|
|
|
|
|
selection: $date,
|
|
|
|
|
displayedComponents: .date
|
|
|
|
|
)
|
|
|
|
|
.datePickerStyle(.compact)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Helper: labelled text field row
|
|
|
|
|
|
|
|
|
|
private struct FormTextField: View {
|
|
|
|
|
let label: String
|
|
|
|
|
@Binding var text: String
|
|
|
|
|
var required: Bool = false
|
|
|
|
|
var placeholder: String = ""
|
|
|
|
|
var keyboard: UIKeyboardType = .default
|
|
|
|
|
|
|
|
|
|
init(_ label: String, text: Binding<String>, required: Bool = false, placeholder: String = "", keyboard: UIKeyboardType = .default) {
|
|
|
|
|
self.label = label
|
|
|
|
|
self._text = text
|
|
|
|
|
self.required = required
|
|
|
|
|
self.placeholder = placeholder
|
|
|
|
|
self.keyboard = keyboard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack {
|
|
|
|
|
Text(label)
|
|
|
|
|
.foregroundStyle(required ? Color(.brandPrimary) : Color(UIColor.label))
|
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
TextField(placeholder.isEmpty ? label : placeholder, text: $text)
|
|
|
|
|
.multilineTextAlignment(.trailing)
|
|
|
|
|
.keyboardType(keyboard)
|
|
|
|
|
.foregroundStyle(Color(UIColor.secondaryLabel))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|