429 lines
14 KiB
Swift
429 lines
14 KiB
Swift
import SwiftUI
|
|
import LabWiseKit
|
|
|
|
// MARK: - Tab shell
|
|
|
|
struct DashboardView: View {
|
|
var body: some View {
|
|
TabView {
|
|
Tab("Dashboard", systemImage: "chart.bar.fill") {
|
|
DashboardHomeView()
|
|
}
|
|
Tab("Inventory", systemImage: "flask.fill") {
|
|
InventoryView()
|
|
}
|
|
Tab("Profile", systemImage: "person.fill") {
|
|
ProfileView()
|
|
}
|
|
}
|
|
.tint(Color(.brandPrimary))
|
|
}
|
|
}
|
|
|
|
// MARK: - Dashboard view model
|
|
|
|
@Observable
|
|
final class DashboardViewModel {
|
|
var chemicals: [Chemical] = []
|
|
var isLoading = false
|
|
var errorMessage: String?
|
|
|
|
private let client = ChemicalsClient()
|
|
|
|
// MARK: Derived stats
|
|
|
|
var lowStock: [Chemical] {
|
|
chemicals
|
|
.filter { ($0.percentageFull ?? 100) < 20 }
|
|
.sorted { ($0.percentageFull ?? 0) < ($1.percentageFull ?? 0) }
|
|
}
|
|
|
|
var expiringSoon: [Chemical] {
|
|
chemicals
|
|
.filter { c in
|
|
guard let exp = c.expirationDate else { return false }
|
|
return daysUntil(exp) <= 30
|
|
}
|
|
.sorted { a, b in
|
|
daysUntil(a.expirationDate ?? "") < daysUntil(b.expirationDate ?? "")
|
|
}
|
|
}
|
|
|
|
var recentActivity: [Chemical] {
|
|
chemicals
|
|
.sorted { ($0.createdAt ?? "") > ($1.createdAt ?? "") }
|
|
.prefix(4)
|
|
.map { $0 }
|
|
}
|
|
|
|
// MARK: Load
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
do {
|
|
chemicals = try await client.list()
|
|
} catch {
|
|
errorMessage = "Failed to load dashboard"
|
|
}
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
func daysUntil(_ iso: String) -> Int {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withFullDate]
|
|
guard let date = formatter.date(from: iso) else { return Int.max }
|
|
return Int(ceil(date.timeIntervalSinceNow / 86400))
|
|
}
|
|
|
|
func relativeTime(_ iso: String?) -> String {
|
|
guard let iso else { return "" }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
guard let date = formatter.date(from: iso) else { return "" }
|
|
let seconds = Int(-date.timeIntervalSinceNow)
|
|
if seconds < 60 { return "just now" }
|
|
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
|
if seconds < 86400 { return "\(seconds / 3600)h ago" }
|
|
return "\(seconds / 86400)d ago"
|
|
}
|
|
}
|
|
|
|
// MARK: - Main dashboard view
|
|
|
|
struct DashboardHomeView: View {
|
|
@State private var viewModel = DashboardViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.chemicals.isEmpty {
|
|
ProgressView("Loading...")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
StatCardsRow(viewModel: viewModel)
|
|
SafetyAlertsSection(viewModel: viewModel)
|
|
LowStockSection(viewModel: viewModel)
|
|
ExpiringSection(viewModel: viewModel)
|
|
RecentActivitySection(viewModel: viewModel)
|
|
}
|
|
.padding()
|
|
}
|
|
.refreshable { await viewModel.load() }
|
|
}
|
|
}
|
|
.navigationTitle("Dashboard")
|
|
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
|
Button("OK") { viewModel.errorMessage = nil }
|
|
} message: {
|
|
Text(viewModel.errorMessage ?? "")
|
|
}
|
|
}
|
|
.task { await viewModel.load() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Stat cards row
|
|
|
|
private struct StatCardsRow: View {
|
|
let viewModel: DashboardViewModel
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
|
StatCard(
|
|
title: "Chemicals",
|
|
value: "\(viewModel.chemicals.count)",
|
|
icon: "flask.fill",
|
|
color: Color(.brandPrimary)
|
|
)
|
|
StatCard(
|
|
title: "Low Stock",
|
|
value: "\(viewModel.lowStock.count)",
|
|
icon: "exclamationmark.triangle.fill",
|
|
color: .orange
|
|
)
|
|
StatCard(
|
|
title: "Expiring ≤30d",
|
|
value: "\(viewModel.expiringSoon.count)",
|
|
icon: "clock.fill",
|
|
color: .red
|
|
)
|
|
StatCard(
|
|
title: "Tracked",
|
|
value: viewModel.chemicals.isEmpty ? "—" : "Active",
|
|
icon: "checkmark.seal.fill",
|
|
color: .green
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(color)
|
|
Spacer()
|
|
}
|
|
Text(value)
|
|
.font(.title.bold())
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding()
|
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
// MARK: - Safety alerts
|
|
|
|
private struct SafetyAlertsSection: View {
|
|
let viewModel: DashboardViewModel
|
|
|
|
private var expired: [Chemical] {
|
|
viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") <= 0 }
|
|
}
|
|
private var lowStockAlerts: [Chemical] { Array(viewModel.lowStock.prefix(2)) }
|
|
private var expiringSoonCount: Int { viewModel.expiringSoon.filter { viewModel.daysUntil($0.expirationDate ?? "") > 0 }.count }
|
|
|
|
var hasAlerts: Bool { !expired.isEmpty || !lowStockAlerts.isEmpty || expiringSoonCount > 0 }
|
|
|
|
var body: some View {
|
|
DashboardCard(title: "Safety Alerts", icon: "shield.fill") {
|
|
if !hasAlerts {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Color(.brandPrimary))
|
|
Text("All clear — no active safety alerts")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
VStack(spacing: 8) {
|
|
ForEach(expired) { chem in
|
|
AlertRow(
|
|
message: "\(chem.chemicalName) 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",
|
|
severity: .warning
|
|
)
|
|
}
|
|
if expiringSoonCount > 0 {
|
|
AlertRow(
|
|
message: "\(expiringSoonCount) chemical(s) expiring in the next 30 days",
|
|
severity: .warning
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum AlertSeverity { case critical, warning }
|
|
|
|
private struct AlertRow: View {
|
|
let message: String
|
|
let severity: AlertSeverity
|
|
|
|
var color: Color { severity == .critical ? .red : .orange }
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 8, height: 8)
|
|
.padding(.top, 5)
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Color(UIColor.label))
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(color.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
|
|
// MARK: - Low stock
|
|
|
|
private struct LowStockSection: View {
|
|
let viewModel: DashboardViewModel
|
|
|
|
var body: some View {
|
|
DashboardCard(title: "Low Stock", icon: "exclamationmark.triangle.fill", iconColor: .orange) {
|
|
if viewModel.lowStock.isEmpty {
|
|
EmptyStateRow(message: "No low stock items")
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(viewModel.lowStock.prefix(3)) { chem in
|
|
ChemAlertRow(
|
|
name: chem.chemicalName,
|
|
subtitle: "\(chem.lab) · \(chem.storageLocation)",
|
|
badge: "\(Int(chem.percentageFull ?? 0))% full",
|
|
badgeColor: .orange
|
|
)
|
|
if chem.id != viewModel.lowStock.prefix(3).last?.id {
|
|
Divider().padding(.leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Expiring soon
|
|
|
|
private struct ExpiringSection: View {
|
|
let viewModel: DashboardViewModel
|
|
|
|
var body: some View {
|
|
DashboardCard(title: "Expiring Soon", icon: "clock.fill", iconColor: .red) {
|
|
if viewModel.expiringSoon.isEmpty {
|
|
EmptyStateRow(message: "No chemicals expiring within 30 days")
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(viewModel.expiringSoon.prefix(4)) { chem in
|
|
let days = viewModel.daysUntil(chem.expirationDate ?? "")
|
|
ChemAlertRow(
|
|
name: chem.chemicalName,
|
|
subtitle: "\(chem.lab) · \(chem.storageLocation)",
|
|
badge: days <= 0 ? "Expired \(-days)d ago" : "In \(days)d",
|
|
badgeColor: days <= 0 ? .red : .orange
|
|
)
|
|
if chem.id != viewModel.expiringSoon.prefix(4).last?.id {
|
|
Divider().padding(.leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent activity
|
|
|
|
private struct RecentActivitySection: View {
|
|
let viewModel: DashboardViewModel
|
|
|
|
var body: some View {
|
|
DashboardCard(title: "Recent Activity", icon: "clock.arrow.circlepath") {
|
|
if viewModel.recentActivity.isEmpty {
|
|
EmptyStateRow(message: "No recent activity")
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(viewModel.recentActivity) { chem in
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "plus.circle.fill")
|
|
.foregroundStyle(Color(.brandPrimary))
|
|
.font(.title3)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Added \(chem.chemicalName) to inventory")
|
|
.font(.subheadline)
|
|
.lineLimit(1)
|
|
Text(viewModel.relativeTime(chem.createdAt))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
if chem.id != viewModel.recentActivity.last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared sub-components
|
|
|
|
private struct DashboardCard<Content: View>: View {
|
|
let title: String
|
|
let icon: String
|
|
var iconColor: Color = Color(.brandPrimary)
|
|
@ViewBuilder let content: () -> Content
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(iconColor)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
content()
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
private struct ChemAlertRow: View {
|
|
let name: String
|
|
let subtitle: String
|
|
let badge: String
|
|
let badgeColor: Color
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(name)
|
|
.font(.subheadline)
|
|
.lineLimit(1)
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Text(badge)
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(badgeColor.opacity(0.12))
|
|
.foregroundStyle(badgeColor)
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
private struct EmptyStateRow: View {
|
|
let message: String
|
|
|
|
var body: some View {
|
|
Text(message)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DashboardView()
|
|
}
|