2026-03-20 02:06:19 -05:00
|
|
|
import SwiftUI
|
|
|
|
|
import LabWiseKit
|
|
|
|
|
|
2026-03-20 02:40:51 -05:00
|
|
|
private let isoDateFormatter: ISO8601DateFormatter = {
|
|
|
|
|
let f = ISO8601DateFormatter()
|
|
|
|
|
f.formatOptions = [.withFullDate]
|
|
|
|
|
return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private let isoTimestampFormatter: ISO8601DateFormatter = {
|
|
|
|
|
let f = ISO8601DateFormatter()
|
|
|
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
|
return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private let displayDateFormatter: DateFormatter = {
|
|
|
|
|
let f = DateFormatter()
|
|
|
|
|
f.dateStyle = .medium
|
|
|
|
|
f.timeStyle = .none
|
|
|
|
|
return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private let displayTimestampFormatter: DateFormatter = {
|
|
|
|
|
let f = DateFormatter()
|
|
|
|
|
f.dateStyle = .medium
|
|
|
|
|
f.timeStyle = .short
|
|
|
|
|
return f
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private func formatDate(_ iso: String?) -> String {
|
|
|
|
|
guard let iso else { return "" }
|
|
|
|
|
if let date = isoDateFormatter.date(from: iso) {
|
|
|
|
|
return displayDateFormatter.string(from: date)
|
|
|
|
|
}
|
|
|
|
|
// Fallback: try full timestamp (e.g. "2025-03-01T00:00:00.000Z")
|
|
|
|
|
if let date = isoTimestampFormatter.date(from: iso) {
|
|
|
|
|
return displayDateFormatter.string(from: date)
|
|
|
|
|
}
|
|
|
|
|
return iso
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func formatTimestamp(_ iso: String?) -> String {
|
|
|
|
|
guard let iso else { return "" }
|
|
|
|
|
if let date = isoTimestampFormatter.date(from: iso) {
|
|
|
|
|
return displayTimestampFormatter.string(from: date)
|
|
|
|
|
}
|
|
|
|
|
return iso
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 02:06:19 -05:00
|
|
|
struct ChemicalDetailView: View {
|
2026-03-20 02:30:15 -05:00
|
|
|
@State private var chemical: Chemical
|
|
|
|
|
@State private var showEdit = false
|
|
|
|
|
|
|
|
|
|
init(chemical: Chemical) {
|
|
|
|
|
self._chemical = State(initialValue: chemical)
|
|
|
|
|
}
|
2026-03-20 02:06:19 -05:00
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
Form {
|
2026-05-01 13:54:05 -05:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 13:54:05 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 02:06:19 -05:00
|
|
|
Section("Storage") {
|
2026-05-01 13:54:05 -05:00
|
|
|
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
|
2026-03-20 02:06:19 -05:00
|
|
|
if let pct = chemical.percentageFull {
|
|
|
|
|
LabeledContent("% Full") {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
PercentageBar(value: pct / 100)
|
|
|
|
|
.frame(width: 80, height: 6)
|
|
|
|
|
Text("\(Int(pct))%")
|
|
|
|
|
.font(.callout)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Section("Vendor") {
|
2026-05-01 13:54:05 -05:00
|
|
|
row("PI First Name", chemical.piFirstName, required: true)
|
|
|
|
|
row("Vendor", chemical.vendor)
|
|
|
|
|
row("Catalog #", chemical.catalogNumber)
|
|
|
|
|
row("Lot #", chemical.lotNumber)
|
2026-03-20 02:40:51 -05:00
|
|
|
if let exp = chemical.expirationDate, !exp.isEmpty {
|
|
|
|
|
LabeledContent("Expiration", value: formatDate(exp))
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
2026-05-01 13:54:05 -05:00
|
|
|
row("Barcode", chemical.barcode)
|
|
|
|
|
row("Contact", chemical.contact)
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let comments = chemical.comments, !comments.isEmpty {
|
|
|
|
|
Section("Comments") {
|
|
|
|
|
Text(comments)
|
|
|
|
|
.font(.body)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Section("Record") {
|
|
|
|
|
if let created = chemical.createdAt {
|
2026-03-20 02:40:51 -05:00
|
|
|
LabeledContent("Created", value: formatTimestamp(created))
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
|
|
|
|
if let updated = chemical.updatedAt {
|
2026-03-20 02:40:51 -05:00
|
|
|
LabeledContent("Updated", value: formatTimestamp(updated))
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-01 13:54:05 -05:00
|
|
|
.navigationTitle(chemical.displayName)
|
2026-03-20 02:06:19 -05:00
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
2026-03-20 02:30:15 -05:00
|
|
|
.toolbar {
|
|
|
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
|
|
|
Button {
|
|
|
|
|
showEdit = true
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "pencil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.sheet(isPresented: $showEdit) {
|
|
|
|
|
AddChemicalView(editing: chemical) { saved in
|
|
|
|
|
showEdit = false
|
|
|
|
|
if saved {
|
|
|
|
|
Task { await reloadChemical() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reload from server so the detail view reflects the saved changes.
|
|
|
|
|
private func reloadChemical() async {
|
|
|
|
|
let updated = try? await ChemicalsClient().list()
|
|
|
|
|
if let match = updated?.first(where: { $0.id == chemical.id }) {
|
|
|
|
|
chemical = match
|
|
|
|
|
}
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|
2026-05-01 13:54:05 -05:00
|
|
|
|
|
|
|
|
/// 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))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-20 02:06:19 -05:00
|
|
|
}
|