import SwiftUI // MARK: - Shared Models struct DashboardStats: Decodable { let totalUsers: Int let activeUsers: Int let totalModules: Int let activeModules: Int let auditEventsToday: Int let systemHealthPercent: Double } struct ManagedUser: Decodable, Identifiable { let id: String let name: String let email: String let role: String let isActive: Bool let lastSeen: Date? let createdAt: Date } struct AppModule: Decodable, Identifiable { let id: String let name: String let description: String let category: String let version: String let isEnabled: Bool let iconName: String } struct AuditEntry: Decodable, Identifiable { let id: String let action: String let resource: String let actorName: String let actorEmail: String let timestamp: Date let severity: AuditSeverity let details: String? enum AuditSeverity: String, Decodable { case info, warning, critical var color: Color { switch self { case .info: return Color(.systemBlue) case .warning: return Color(.systemOrange) case .critical: return Color(.systemRed) } } var icon: String { switch self { case .info: return "info.circle.fill" case .warning: return "exclamationmark.triangle.fill" case .critical: return "xmark.circle.fill" } } var label: String { switch self { case .info: return "Info" case .warning: return "Varning" case .critical: return "Kritisk" } } } } // MARK: - Dashboard ViewModel @MainActor final class DashboardViewModel: ObservableObject { @Published var stats: DashboardStats? @Published var recentActivity: [AuditEntry] = [] @Published var isLoading = false @Published var error: String? private let api = APIClient.shared func load() async { isLoading = true error = nil defer { isLoading = false } do { async let statsReq: DashboardStats = api.get("/dashboard/stats") async let auditReq: [AuditEntry] = api.get("/audit?limit=5&sort=desc") let (s, a) = try await (statsReq, auditReq) stats = s recentActivity = a } catch { self.error = error.localizedDescription } } } // MARK: - Dashboard View struct DashboardView: View { @StateObject private var vm = DashboardViewModel() var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 16) { if vm.isLoading && vm.stats == nil { skeletonCards(count: 4) } else if let err = vm.error, vm.stats == nil { inlineError(err) { Task { await vm.load() } } } else { if let stats = vm.stats { statsSection(stats) } if !vm.recentActivity.isEmpty { recentSection } } } .padding(16) } .navigationTitle("Dashboard") .background(Color(.systemBackground)) .refreshable { await vm.load() } } .task { await vm.load() } } private func statsSection(_ s: DashboardStats) -> some View { VStack(spacing: 8) { Text("Översikt") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { StatCard( title: "Användare", value: "\(s.activeUsers)/\(s.totalUsers)", subtitle: "aktiva", icon: "person.2.fill", color: Color(.systemBlue) ) StatCard( title: "Moduler", value: "\(s.activeModules)/\(s.totalModules)", subtitle: "aktiverade", icon: "square.grid.2x2.fill", color: Color(.systemGreen) ) StatCard( title: "Händelser", value: "\(s.auditEventsToday)", subtitle: "idag", icon: "doc.text.fill", color: Color(.systemOrange) ) StatCard( title: "Systemhälsa", value: "\(Int(s.systemHealthPercent))%", subtitle: healthLabel(s.systemHealthPercent), icon: "heart.fill", color: healthColor(s.systemHealthPercent) ) } } } private var recentSection: some View { VStack(spacing: 8) { Text("Senaste aktivitet") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) LazyVStack(spacing: 8) { ForEach(vm.recentActivity) { entry in DashboardAuditRow(entry: entry) } } } } private func healthLabel(_ v: Double) -> String { v >= 90 ? "utmärkt" : v >= 70 ? "bra" : "varning" } private func healthColor(_ v: Double) -> Color { v >= 90 ? Color(.systemGreen) : v >= 70 ? Color(.systemOrange) : Color(.systemRed) } private func skeletonCards(count: Int) -> some View { LazyVStack(spacing: 8) { ForEach(0.. Void @Environment(\.dismiss) private var dismiss private var initials: String { user.name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined() } var body: some View { ScrollView { LazyVStack(spacing: 16) { profileHeader infoCard actionCard } .padding(16) } .navigationTitle(user.name) .navigationBarTitleDisplayMode(.large) .background(Color(.systemBackground)) } private var profileHeader: some View { VStack(spacing: 12) { ZStack { Circle() .fill(user.isActive ? Color(.systemBlue).opacity(0.15) : Color(.systemGray5)) Text(initials) .font(.system(size: 32, weight: .semibold)) .foregroundStyle(user.isActive ? Color(.systemBlue) : Color(.systemGray)) } .frame(width: 80, height: 80) VStack(spacing: 4) { Text(user.name).font(.title2.bold()) Text(user.email) .font(.callout) .foregroundStyle(.secondary) } StatusBadge(isActive: user.isActive) } .padding(24) .frame(maxWidth: .infinity) .background(Color(.secondarySystemBackground)) .cornerRadius(12) } private var infoCard: some View { VStack(spacing: 8) { Text("Information") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { InfoRow(label: "Roll", value: user.role.capitalized) Divider().padding(.leading, 16) InfoRow( label: "Skapad", value: user.createdAt.formatted(date: .abbreviated, time: .omitted) ) if let lastSeen = user.lastSeen { Divider().padding(.leading, 16) InfoRow( label: "Senast aktiv", value: lastSeen.formatted(.relative(presentation: .named)) ) } } .background(Color(.secondarySystemBackground)) .cornerRadius(12) } } private var actionCard: some View { VStack(spacing: 8) { Text("Åtgärder") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) Button { onToggle() dismiss() } label: { HStack(spacing: 12) { Image(systemName: user.isActive ? "person.slash.fill" : "person.fill.checkmark") Text(user.isActive ? "Inaktivera användare" : "Aktivera användare") .font(.callout.weight(.medium)) Spacer() } .padding(16) .background( user.isActive ? Color(.systemRed).opacity(0.1) : Color(.systemGreen).opacity(0.1) ) .foregroundStyle(user.isActive ? Color(.systemRed) : Color(.systemGreen)) .cornerRadius(12) } } } } struct StatusBadge: View { let isActive: Bool var body: some View { HStack(spacing: 6) { Circle() .fill(isActive ? Color(.systemGreen) : Color(.systemGray3)) .frame(width: 8, height: 8) Text(isActive ? "Aktiv" : "Inaktiv") .font(.footnote.weight(.medium)) .foregroundStyle(isActive ? Color(.systemGreen) : Color(.systemGray)) } .padding(.horizontal, 10) .padding(.vertical, 5) .background(isActive ? Color(.systemGreen).opacity(0.1) : Color(.systemGray5)) .cornerRadius(20) } } struct InfoRow: View { let label: String let value: String var body: some View { HStack { Text(label) .font(.callout) .foregroundStyle(.secondary) Spacer() Text(value) .font(.callout.weight(.medium)) .foregroundStyle(.primary) } .padding(.horizontal, 16) .padding(.vertical, 12) } } struct RoleFilterMenu: View { let roles: [String] @Binding var selected: String? var body: some View { Menu { Button { selected = nil } label: { Label("Alla roller", systemImage: selected == nil ? "checkmark" : "") } ForEach(roles, id: \.self) { role in Button { selected = selected == role ? nil : role } label: { Label(role.capitalized, systemImage: selected == role ? "checkmark" : "") } } } label: { Image(systemName: selected != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(Color(.systemBlue)) } } } // MARK: - Modules ViewModel @MainActor final class ModulesViewModel: ObservableObject { @Published var modules: [AppModule] = [] @Published var selectedCategory: String? = nil @Published var isLoading = false @Published var togglingId: String? = nil @Published var error: String? private let api = APIClient.shared var categories: [String] { Array(Set(modules.map(\.category))).sorted() } var filtered: [AppModule] { guard let cat = selectedCategory else { return modules } return modules.filter { $0.category == cat } } func load() async { isLoading = true error = nil defer { isLoading = false } do { modules = try await api.get("/modules") } catch { self.error = error.localizedDescription } } func toggle(_ module: AppModule) async { guard togglingId == nil else { return } togglingId = module.id defer { togglingId = nil } struct Body: Encodable { let isEnabled: Bool } do { let updated: AppModule = try await api.patch( "/modules/\(module.id)", body: Body(isEnabled: !module.isEnabled) ) if let idx = modules.firstIndex(where: { $0.id == updated.id }) { modules[idx] = updated } } catch { self.error = error.localizedDescription } } } // MARK: - Modules View struct ModulesView: View { @StateObject private var vm = ModulesViewModel() private let columns = [GridItem(.flexible()), GridItem(.flexible())] var body: some View { NavigationStack { Group { if vm.isLoading && vm.modules.isEmpty { skeletonGrid } else if let err = vm.error, vm.modules.isEmpty { inlineError(err) { Task { await vm.load() } } } else { moduleGrid } } .navigationTitle("Moduler") .toolbar { if !vm.categories.isEmpty { ToolbarItem(placement: .topBarTrailing) { CategoryFilterMenu( categories: vm.categories, selected: $vm.selectedCategory ) } } } .refreshable { await vm.load() } } .task { await vm.load() } } private var moduleGrid: some View { ScrollView { LazyVGrid(columns: columns, spacing: 8) { ForEach(vm.filtered) { module in ModuleCardView( module: module, isToggling: vm.togglingId == module.id ) { Task { await vm.toggle(module) } } } } .padding(16) } } private var skeletonGrid: some View { ScrollView { LazyVGrid(columns: columns, spacing: 8) { ForEach(0..<6, id: \.self) { _ in RoundedRectangle(cornerRadius: 12) .fill(Color(.secondarySystemBackground)) .frame(height: 150) } } .padding(16) } } } struct ModuleCardView: View { let module: AppModule let isToggling: Bool let onToggle: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: module.iconName) .font(.title3.weight(.medium)) .foregroundStyle(module.isEnabled ? Color(.systemBlue) : Color(.systemGray)) Spacer() if isToggling { ProgressView().scaleEffect(0.75) } else { Toggle("", isOn: Binding( get: { module.isEnabled }, set: { _ in onToggle() } )) .labelsHidden() .tint(Color(.systemGreen)) .scaleEffect(0.8) } } VStack(alignment: .leading, spacing: 4) { Text(module.name) .font(.callout.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) Text(module.description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) HStack { Text(module.category) .font(.caption2.weight(.medium)) .padding(.horizontal, 6) .padding(.vertical, 3) .background(Color(.systemGray5)) .foregroundStyle(.secondary) .cornerRadius(4) Spacer() Text("v\(module.version)") .font(.caption2) .foregroundStyle(.tertiary) } } .padding(16) .frame(minHeight: 150, alignment: .top) .background(Color(.secondarySystemBackground)) .cornerRadius(12) .opacity(module.isEnabled ? 1.0 : 0.65) } } struct CategoryFilterMenu: View { let categories: [String] @Binding var selected: String? var body: some View { Menu { Button { selected = nil } label: { Label("Alla kategorier", systemImage: selected == nil ? "checkmark" : "") } ForEach(categories, id: \.self) { cat in Button { selected = selected == cat ? nil : cat } label: { Label(cat, systemImage: selected == cat ? "checkmark" : "") } } } label: { Image(systemName: selected != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(Color(.systemBlue)) } } } // MARK: - Audit ViewModel @MainActor final class AuditViewModel: ObservableObject { @Published var entries: [AuditEntry] = [] @Published var selectedSeverity: AuditEntry.AuditSeverity? = nil @Published var isLoading = false @Published var isLoadingMore = false @Published var hasMore = true @Published var error: String? private let api = APIClient.shared private let pageSize = 25 private var page = 0 var filtered: [AuditEntry] { guard let sev = selectedSeverity else { return entries } return entries.filter { $0.severity == sev } } func load() async { page = 0 hasMore = true isLoading = true error = nil defer { isLoading = false } do { let result: [AuditEntry] = try await api.get("/audit?page=0&limit=\(pageSize)&sort=desc") entries = result hasMore = result.count == pageSize page = 1 } catch { self.error = error.localizedDescription } } func loadMore() async { guard hasMore, !isLoadingMore else { return } isLoadingMore = true defer { isLoadingMore = false } do { let result: [AuditEntry] = try await api.get("/audit?page=\(page)&limit=\(pageSize)&sort=desc") entries.append(contentsOf: result) hasMore = result.count == pageSize page += 1 } catch { self.error = error.localizedDescription } } } // MARK: - Audit View struct AuditView: View { @StateObject private var vm = AuditViewModel() var body: some View { NavigationStack { Group { if vm.isLoading && vm.entries.isEmpty { skeletonList } else if let err = vm.error, vm.entries.isEmpty { inlineError(err) { Task { await vm.load() } } } else { auditList } } .navigationTitle("Auditlogg") .toolbar { ToolbarItem(placement: .topBarTrailing) { SeverityFilterMenu(selected: $vm.selectedSeverity) } } .refreshable { await vm.load() } } .task { await vm.load() } } private var auditList: some View { ScrollView { LazyVStack(spacing: 8) { ForEach(vm.filtered) { entry in AuditRowView(entry: entry) .onAppear { if entry.id == vm.filtered.last?.id { Task { await vm.loadMore() } } } } if vm.isLoadingMore { ProgressView().padding(16) } } .padding(16) } } private var skeletonList: some View { ScrollView { LazyVStack(spacing: 8) { ForEach(0..<10, id: \.self) { _ in RoundedRectangle(cornerRadius: 12) .fill(Color(.secondarySystemBackground)) .frame(height: 76) } } .padding(16) } } } struct AuditRowView: View { let entry: AuditEntry @State private var expanded = false var body: some View { VStack(alignment: .leading, spacing: 0) { Button { withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } } label: { HStack(spacing: 12) { Image(systemName: entry.severity.icon) .font(.callout) .foregroundStyle(entry.severity.color) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(entry.action) .font(.callout.weight(.medium)) .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 4) { Text(entry.actorName) Text("·") Text(entry.resource) } .font(.caption) .foregroundStyle(.secondary) } VStack(alignment: .trailing, spacing: 2) { Text(entry.timestamp, style: .time) Text(entry.timestamp, style: .date) } .font(.caption2) .foregroundStyle(.tertiary) Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) .foregroundStyle(Color(.tertiaryLabel)) .rotationEffect(.degrees(expanded ? 90 : 0)) } .padding(12) } .buttonStyle(.plain) if expanded, let details = entry.details, !details.isEmpty { Divider().padding(.leading, 48) Text(details) .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 48) .padding(.trailing, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) } } .background(Color(.secondarySystemBackground)) .cornerRadius(12) .clipped() } } struct SeverityFilterMenu: View { @Binding var selected: AuditEntry.AuditSeverity? var body: some View { Menu { Button { selected = nil } label: { Label("Alla nivåer", systemImage: selected == nil ? "checkmark" : "") } ForEach([AuditEntry.AuditSeverity.info, .warning, .critical], id: \.self) { sev in Button { selected = selected == sev ? nil : sev } label: { Label(sev.label, systemImage: selected == sev ? "checkmark" : "") } } } label: { Image(systemName: selected != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(Color(.systemBlue)) } } } // MARK: - Settings View struct SettingsView: View { @EnvironmentObject private var auth: AuthViewModel @State private var showLogoutConfirm = false @State private var notificationsEnabled = true @State private var biometricEnabled = false @AppStorage("prefersDarkMode") private var prefersDarkMode = false var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 16) { profileSection preferencesSection securitySection aboutSection logoutButton } .padding(16) } .navigationTitle("Inställningar") .background(Color(.systemBackground)) } .confirmationDialog( "Logga ut", isPresented: $showLogoutConfirm, titleVisibility: .visible ) { Button("Logga ut", role: .destructive) { auth.logout() } Button("Avbryt", role: .cancel) {} } message: { Text("Är du säker på att du vill logga ut?") } } private var profileSection: some View { Group { if let user = auth.currentUser { HStack(spacing: 16) { ZStack { Circle() .fill(Color(.systemBlue).opacity(0.15)) Text(initials(user.name)) .font(.title3.weight(.semibold)) .foregroundStyle(Color(.systemBlue)) } .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 4) { Text(user.name).font(.headline) Text(user.email) .font(.callout) .foregroundStyle(.secondary) Text(user.role.capitalized) .font(.caption.weight(.medium)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color(.systemBlue).opacity(0.1)) .foregroundStyle(Color(.systemBlue)) .cornerRadius(6) } Spacer() } .padding(16) .background(Color(.secondarySystemBackground)) .cornerRadius(12) } } } private var preferencesSection: some View { VStack(spacing: 8) { Text("Inställningar") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { SettingsToggleRow( icon: "bell.fill", iconColor: Color(.systemRed), title: "Notifikationer", isOn: $notificationsEnabled ) Divider().padding(.leading, 56) SettingsToggleRow( icon: "moon.fill", iconColor: Color(.systemIndigo), title: "Mörkt läge", isOn: $prefersDarkMode ) } .background(Color(.secondarySystemBackground)) .cornerRadius(12) } } private var securitySection: some View { VStack(spacing: 8) { Text("Säkerhet") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { SettingsToggleRow( icon: "faceid", iconColor: Color(.systemGreen), title: "Face ID / Touch ID", isOn: $biometricEnabled ) Divider().padding(.leading, 56) SettingsNavRow( icon: "lock.rotation", iconColor: Color(.systemOrange), title: "Byt lösenord" ) {} Divider().padding(.leading, 56) SettingsNavRow( icon: "key.fill", iconColor: Color(.systemBlue), title: "API-nycklar" ) {} } .background(Color(.secondarySystemBackground)) .cornerRadius(12) } } private var aboutSection: some View { VStack(spacing: 8) { Text("Om") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { InfoRow( label: "Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" ) Divider().padding(.leading, 16) InfoRow( label: "Build", value: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" ) Divider().padding(.leading, 16) InfoRow(label: "Server", value: APIClient.shared.baseURL) } .background(Color(.secondarySystemBackground)) .cornerRadius(12) } } private var logoutButton: some View { Button { showLogoutConfirm = true } label: { HStack { Spacer() Image(systemName: "rectangle.portrait.and.arrow.right") Text("Logga ut").font(.callout.weight(.semibold)) Spacer() } .padding(16) .background(Color(.systemRed).opacity(0.1)) .foregroundStyle(Color(.systemRed)) .cornerRadius(12) } } private func initials(_ name: String) -> String { name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined() } } struct SettingsToggleRow: View { let icon: String let iconColor: Color let title: String @Binding var isOn: Bool var body: some View { HStack(spacing: 12) { iconView Text(title).font(.callout).foregroundStyle(.primary) Spacer() Toggle("", isOn: $isOn).labelsHidden().tint(Color(.systemGreen)) } .padding(.horizontal, 16) .padding(.vertical, 12) } private var iconView: some View { ZStack { RoundedRectangle(cornerRadius: 8).fill(iconColor) Image(systemName: icon) .font(.callout.weight(.medium)) .foregroundStyle(.white) } .frame(width: 32, height: 32) } } struct SettingsNavRow: View { let icon: String let iconColor: Color let title: String let action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 12) { ZStack { RoundedRectangle(cornerRadius: 8).fill(iconColor) Image(systemName: icon) .font(.callout.weight(.medium)) .foregroundStyle(.white) } .frame(width: 32, height: 32) Text(title).font(.callout).foregroundStyle(.primary) Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Color(.tertiaryLabel)) } .padding(.horizontal, 16) .padding(.vertical, 12) } .buttonStyle(.plain) } } // MARK: - Shared Error Component private func inlineError(_ message: String, retry: @escaping () -> Void) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 40)) .foregroundStyle(Color(.systemOrange)) Text(message) .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Försök igen", action: retry) .buttonStyle(.borderedProminent) .tint(Color(.systemBlue)) } .padding(40) .frame(maxWidth: .infinity) .background(Color(.secondarySystemBackground)) .cornerRadius(12) .padding(16) } // MARK: - Content View struct ContentView: View { @State private var selectedTab = 0 var body: some View { TabView(selection: $selectedTab) { DashboardView() .tabItem { Label("Dashboard", systemImage: "house.fill") } .tag(0) UsersView() .tabItem { Label("Användare", systemImage: "person.2.fill") } .tag(1) ModulesView() .tabItem { Label("Moduler", systemImage: "square.grid.2x2.fill") } .tag(2) AuditView() .tabItem { Label("Logg", systemImage: "doc.text.fill") } .tag(3) SettingsView() .tabItem { Label("Inställningar", systemImage: "gear") } .tag(4) } .tint(Color(.systemBlue)) } }