import SwiftUI // MARK: - Models enum AuditAction: String, CaseIterable, Identifiable, Codable { case create = "create" case update = "update" case delete = "delete" case login = "login" case logout = "logout" case export = "export" case import_ = "import" case assign = "assign" var id: String { rawValue } var label: String { switch self { case .create: return "Skapad" case .update: return "Uppdaterad" case .delete: return "Raderad" case .login: return "Inloggning" case .logout: return "Utloggning" case .export: return "Exporterad" case .import_: return "Importerad" case .assign: return "Tilldelad" } } var symbol: String { switch self { case .create: return "plus.circle.fill" case .update: return "pencil.circle.fill" case .delete: return "trash.circle.fill" case .login: return "person.crop.circle.badge.checkmark" case .logout: return "person.crop.circle.badge.xmark" case .export: return "arrow.up.circle.fill" case .import_: return "arrow.down.circle.fill" case .assign: return "person.2.circle.fill" } } var color: Color { switch self { case .create: return Color(.systemGreen) case .update: return Color(.systemBlue) case .delete: return Color(.systemRed) case .login: return Color(.systemGreen) case .logout: return Color(.systemOrange) case .export: return Color(.systemBlue) case .import_: return Color(.systemBlue) case .assign: return Color(.systemOrange) } } } struct AuditEntry: Identifiable, Equatable { let id: UUID let action: AuditAction let userName: String let userInitials: String let resource: String let resourceType: String let detail: String? let timestamp: Date static func == (lhs: AuditEntry, rhs: AuditEntry) -> Bool { lhs.id == rhs.id } } struct AuditFilter: Equatable { var selectedActions: Set = [] var fromDate: Date? var toDate: Date? var isActive: Bool { !selectedActions.isEmpty || fromDate != nil || toDate != nil } } // MARK: - View Model @MainActor final class AuditViewModel: ObservableObject { @Published var entries: [AuditEntry] = [] @Published var isLoading = false @Published var isRefreshing = false @Published var hasMore = true @Published var filter = AuditFilter() @Published var error: String? private var currentPage = 0 private let pageSize = 25 private var allSeeds: [AuditEntry] = [] init() { allSeeds = Self.generateSeeds(count: 200) } func loadInitial() async { guard !isLoading else { return } isLoading = true currentPage = 0 entries = [] hasMore = true await fetchPage() isLoading = false } func refresh() async { isRefreshing = true currentPage = 0 entries = [] hasMore = true await fetchPage() isRefreshing = false } func loadMore() async { guard !isLoading, !isRefreshing, hasMore else { return } isLoading = true await fetchPage() isLoading = false } private func fetchPage() async { // Simulate network latency try? await Task.sleep(for: .milliseconds(400)) let filtered = filteredSeeds() let start = currentPage * pageSize guard start < filtered.count else { hasMore = false return } let end = min(start + pageSize, filtered.count) let page = Array(filtered[start.. [AuditEntry] { allSeeds.filter { entry in if !filter.selectedActions.isEmpty, !filter.selectedActions.contains(entry.action) { return false } if let from = filter.fromDate, entry.timestamp < from { return false } if let to = filter.toDate, entry.timestamp > to { return false } return true } } func applyFilter(_ newFilter: AuditFilter) { filter = newFilter Task { await loadInitial() } } // MARK: Seed data private static func generateSeeds(count: Int) -> [AuditEntry] { let users: [(String, String)] = [ ("Anna Lindström", "AL"), ("Erik Svensson", "ES"), ("Maria Karlsson", "MK"), ("Johan Persson", "JP"), ("Sara Nilsson", "SN"), ("Lars Eriksson", "LE"), ] let resources: [(String, String)] = [ ("Ärende #4821", "case"), ("Klient Bäckström AB", "company"), ("Rapport Q1-2026", "document"), ("Faktura INV-00291", "invoice"), ("Kontrakt #7743", "contract"), ("Användare admin@aamos.se", "user"), ] let actions = AuditAction.allCases var results: [AuditEntry] = [] for i in 0.. String { switch type { case "case": return "briefcase" case "company": return "building.2" case "document": return "doc.text" case "invoice": return "doc.badge.gearshape" case "contract": return "signature" case "user": return "person" default: return "square.grid.2x2" } } } // MARK: - ActionBadge struct ActionBadge: View { let action: AuditAction var body: some View { Text(action.label) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(action.color) .padding(.horizontal, 6) .padding(.vertical, 2) .background(action.color.opacity(0.12)) .clipShape(Capsule()) } } // MARK: - FilterSheet struct FilterSheet: View { @Binding var filter: AuditFilter let onApply: (AuditFilter) -> Void @Environment(\.dismiss) private var dismiss @State private var draft: AuditFilter @State private var showFromPicker = false @State private var showToPicker = false init(filter: Binding, onApply: @escaping (AuditFilter) -> Void) { self._filter = filter self.onApply = onApply self._draft = State(initialValue: filter.wrappedValue) } var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { actionSection dateSection } .padding(16) } .background(Color(.secondarySystemBackground)) .navigationTitle("Filter") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Avbryt") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Tillämpa") { onApply(draft) dismiss() } .fontWeight(.semibold) } ToolbarItem(placement: .bottomBar) { Button(role: .destructive) { draft = AuditFilter() } label: { Label("Rensa filter", systemImage: "xmark.circle") } .disabled(!draft.isActive) } } } } private var actionSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Åtgärdstyp") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(.secondaryLabel)) .textCase(.uppercase) VStack(spacing: 0) { ForEach(AuditAction.allCases) { action in let selected = draft.selectedActions.contains(action) Button { if selected { draft.selectedActions.remove(action) } else { draft.selectedActions.insert(action) } } label: { HStack(spacing: 12) { Image(systemName: action.symbol) .font(.system(size: 17)) .foregroundStyle(action.color) .frame(width: 28) Text(action.label) .font(.system(size: 16)) .foregroundStyle(Color(.label)) Spacer() if selected { Image(systemName: "checkmark") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color(.systemBlue)) } } .padding(.horizontal, 16) .padding(.vertical, 12) } .buttonStyle(.plain) if action != AuditAction.allCases.last { Divider().padding(.leading, 56) } } } .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } private var dateSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Tidsperiod") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color(.secondaryLabel)) .textCase(.uppercase) VStack(spacing: 0) { DatePickerRow( label: "Från", symbol: "calendar.badge.clock", date: $draft.fromDate, maximumDate: draft.toDate ?? Date() ) Divider().padding(.leading, 56) DatePickerRow( label: "Till", symbol: "calendar.badge.checkmark", date: $draft.toDate, minimumDate: draft.fromDate, maximumDate: Date() ) } .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } } struct DatePickerRow: View { let label: String let symbol: String @Binding var date: Date? var minimumDate: Date? = nil var maximumDate: Date? = nil @State private var expanded = false private var displayDate: String { guard let d = date else { return "Välj datum" } let f = DateFormatter() f.dateStyle = .medium f.locale = Locale(identifier: "sv_SE") return f.string(from: d) } var body: some View { VStack(spacing: 0) { Button { withAnimation(.spring(duration: 0.25)) { expanded.toggle() if expanded && date == nil { date = Date() } if !expanded { } } } label: { HStack(spacing: 12) { Image(systemName: symbol) .font(.system(size: 17)) .foregroundStyle(Color(.systemBlue)) .frame(width: 28) Text(label) .font(.system(size: 16)) .foregroundStyle(Color(.label)) Spacer() Text(displayDate) .font(.system(size: 15)) .foregroundStyle(date == nil ? Color(.tertiaryLabel) : Color(.secondaryLabel)) if date != nil { Button { date = nil expanded = false } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Color(.tertiaryLabel)) } .buttonStyle(.plain) } Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Color(.tertiaryLabel)) .rotationEffect(.degrees(expanded ? 90 : 0)) } .padding(.horizontal, 16) .padding(.vertical, 12) } .buttonStyle(.plain) if expanded { Divider().padding(.leading, 56) DatePicker( "", selection: Binding( get: { date ?? Date() }, set: { date = $0 } ), in: (minimumDate ?? .distantPast)...(maximumDate ?? .distantFuture), displayedComponents: .date ) .datePickerStyle(.graphical) .padding(.horizontal, 8) .environment(\.locale, Locale(identifier: "sv_SE")) } } } } // MARK: - AuditView struct AuditView: View { @StateObject private var vm = AuditViewModel() @State private var showFilter = false var body: some View { NavigationStack { ZStack { Color(.secondarySystemBackground).ignoresSafeArea() content } .navigationTitle("Granskningslogg") .navigationBarTitleDisplayMode(.large) .toolbar { toolbarContent } .sheet(isPresented: $showFilter) { FilterSheet(filter: $vm.filter) { newFilter in vm.applyFilter(newFilter) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } .task { await vm.loadInitial() } } } // MARK: Content @ViewBuilder private var content: some View { if vm.isLoading && vm.entries.isEmpty { loadingPlaceholder } else if vm.entries.isEmpty { emptyState } else { entryList } } private var entryList: some View { ScrollView { LazyVStack(spacing: 8, pinnedViews: []) { if vm.filter.isActive { filterBanner } ForEach(vm.entries) { entry in AuditRow(entry: entry) .padding(.horizontal, 16) .onAppear { if entry == vm.entries.last { Task { await vm.loadMore() } } } } if vm.isLoading && !vm.entries.isEmpty { ProgressView() .padding(.vertical, 16) .frame(maxWidth: .infinity) } if !vm.hasMore && !vm.entries.isEmpty { Text("Inga fler händelser") .font(.system(size: 13)) .foregroundStyle(Color(.tertiaryLabel)) .padding(.vertical, 20) .frame(maxWidth: .infinity) } } .padding(.vertical, 8) } .refreshable { await vm.refresh() } } private var filterBanner: some View { HStack(spacing: 8) { Image(systemName: "line.3.horizontal.decrease.circle.fill") .foregroundStyle(Color(.systemBlue)) Text(filterSummary) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color(.secondaryLabel)) Spacer() Button { vm.applyFilter(AuditFilter()) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Color(.tertiaryLabel)) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color(.systemBlue).opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) } private var filterSummary: String { var parts: [String] = [] if !vm.filter.selectedActions.isEmpty { parts.append(vm.filter.selectedActions.map(\.label).joined(separator: ", ")) } let df = DateFormatter() df.dateStyle = .short df.locale = Locale(identifier: "sv_SE") if let from = vm.filter.fromDate { parts.append("från \(df.string(from: from))") } if let to = vm.filter.toDate { parts.append("till \(df.string(from: to))") } return parts.joined(separator: " · ") } // MARK: Loading / Empty private var loadingPlaceholder: some View { LazyVStack(spacing: 8) { ForEach(0..<8, id: \.self) { _ in ShimmerRow() .padding(.horizontal, 16) } } .padding(.top, 8) .allowsHitTesting(false) } private var emptyState: some View { VStack(spacing: 16) { Image(systemName: "doc.text.magnifyingglass") .font(.system(size: 48, weight: .light)) .foregroundStyle(Color(.tertiaryLabel)) Text("Inga händelser hittades") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(Color(.label)) if vm.filter.isActive { Text("Prova att justera ditt filter") .font(.system(size: 14)) .foregroundStyle(Color(.secondaryLabel)) Button("Rensa filter") { vm.applyFilter(AuditFilter()) } .buttonStyle(.bordered) .controlSize(.regular) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: Toolbar @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Button { showFilter = true } label: { ZStack(alignment: .topTrailing) { Image(systemName: "line.3.horizontal.decrease.circle") .font(.system(size: 20)) .foregroundStyle(Color(.systemBlue)) if vm.filter.isActive { Circle() .fill(Color(.systemRed)) .frame(width: 8, height: 8) .offset(x: 4, y: -2) } } } } } } // MARK: - Shimmer placeholder struct ShimmerRow: View { @State private var phase: CGFloat = -1 var body: some View { HStack(alignment: .top, spacing: 12) { Circle() .fill(Color(.systemFill)) .frame(width: 40, height: 40) VStack(alignment: .leading, spacing: 6) { RoundedRectangle(cornerRadius: 4) .fill(Color(.systemFill)) .frame(width: 180, height: 14) RoundedRectangle(cornerRadius: 4) .fill(Color(.systemFill)) .frame(width: 120, height: 12) } Spacer() } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay { GeometryReader { geo in let w = geo.size.width LinearGradient( colors: [.clear, Color(.systemBackground).opacity(0.6), .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: w * 0.4) .offset(x: phase * w) .blendMode(.overlay) } .clipShape(RoundedRectangle(cornerRadius: 12)) } .onAppear { withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { phase = 1.6 } } } } // MARK: - Preview #Preview { AuditView() }