6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
1313 lines
40 KiB
Swift
1313 lines
40 KiB
Swift
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..<count, id: \.self) { _ in
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemBackground))
|
|
.frame(height: 88)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let subtitle: String
|
|
let icon: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.callout)
|
|
.foregroundStyle(color)
|
|
|
|
Text(value)
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.primary)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
Text(subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(color)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.secondarySystemBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
struct DashboardAuditRow: View {
|
|
let entry: AuditEntry
|
|
|
|
var body: some View {
|
|
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)
|
|
Text(entry.actorName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(entry.timestamp, style: .relative)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
// MARK: - Users ViewModel
|
|
|
|
@MainActor
|
|
final class UsersViewModel: ObservableObject {
|
|
@Published var users: [ManagedUser] = []
|
|
@Published var filteredUsers: [ManagedUser] = []
|
|
@Published var searchText = "" { didSet { applyFilter() } }
|
|
@Published var selectedRole: String? = nil { didSet { applyFilter() } }
|
|
@Published var isLoading = false
|
|
@Published var error: String?
|
|
|
|
private let api = APIClient.shared
|
|
|
|
var availableRoles: [String] {
|
|
Array(Set(users.map(\.role))).sorted()
|
|
}
|
|
|
|
func load() async {
|
|
isLoading = true
|
|
error = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
users = try await api.get("/users")
|
|
applyFilter()
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func toggleStatus(_ user: ManagedUser) async {
|
|
struct Body: Encodable { let isActive: Bool }
|
|
do {
|
|
let updated: ManagedUser = try await api.patch(
|
|
"/users/\(user.id)",
|
|
body: Body(isActive: !user.isActive)
|
|
)
|
|
if let idx = users.firstIndex(where: { $0.id == updated.id }) {
|
|
users[idx] = updated
|
|
applyFilter()
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func applyFilter() {
|
|
var result = users
|
|
if let role = selectedRole { result = result.filter { $0.role == role } }
|
|
if !searchText.isEmpty {
|
|
let q = searchText.lowercased()
|
|
result = result.filter {
|
|
$0.name.lowercased().contains(q) || $0.email.lowercased().contains(q)
|
|
}
|
|
}
|
|
filteredUsers = result
|
|
}
|
|
}
|
|
|
|
// MARK: - Users View
|
|
|
|
struct UsersView: View {
|
|
@StateObject private var vm = UsersViewModel()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if vm.isLoading && vm.users.isEmpty {
|
|
skeletonList
|
|
} else if let err = vm.error, vm.users.isEmpty {
|
|
inlineError(err) { Task { await vm.load() } }
|
|
} else {
|
|
userList
|
|
}
|
|
}
|
|
.navigationTitle("Användare")
|
|
.searchable(text: $vm.searchText, prompt: "Sök namn eller e-post")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
RoleFilterMenu(
|
|
roles: vm.availableRoles,
|
|
selected: $vm.selectedRole
|
|
)
|
|
}
|
|
}
|
|
.refreshable { await vm.load() }
|
|
}
|
|
.task { await vm.load() }
|
|
}
|
|
|
|
private var userList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
if vm.filteredUsers.isEmpty {
|
|
emptySearchState
|
|
} else {
|
|
ForEach(vm.filteredUsers) { user in
|
|
NavigationLink {
|
|
UserDetailView(user: user) {
|
|
Task { await vm.toggleStatus(user) }
|
|
}
|
|
} label: {
|
|
UserRowView(user: user)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private var skeletonList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(0..<8, id: \.self) { _ in
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.secondarySystemBackground))
|
|
.frame(height: 72)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
|
|
private var emptySearchState: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "person.slash")
|
|
.font(.system(size: 40))
|
|
.foregroundStyle(.secondary)
|
|
Text("Inga användare hittades")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(48)
|
|
}
|
|
}
|
|
|
|
struct UserRowView: View {
|
|
let user: ManagedUser
|
|
|
|
private var initials: String {
|
|
user.name.split(separator: " ").prefix(2).compactMap(\.first).map(String.init).joined()
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(user.isActive ? Color(.systemBlue).opacity(0.15) : Color(.systemGray5))
|
|
Text(initials)
|
|
.font(.callout.weight(.semibold))
|
|
.foregroundStyle(user.isActive ? Color(.systemBlue) : Color(.systemGray))
|
|
}
|
|
.frame(width: 44, height: 44)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(user.name)
|
|
.font(.callout.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
Text(user.email)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
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)
|
|
|
|
Circle()
|
|
.fill(user.isActive ? Color(.systemGreen) : Color(.systemGray3))
|
|
.frame(width: 8, height: 8)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(Color(.tertiaryLabel))
|
|
}
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
|
|
struct UserDetailView: View {
|
|
let user: ManagedUser
|
|
let onToggle: () -> 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))
|
|
}
|
|
}
|