Files
boc/aamos-admin-upgrade/ios/AamosAdmin/Views/AuditView.swift
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- 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
2026-07-08 19:56:03 +00:00

729 lines
24 KiB
Swift

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<AuditAction> = []
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..<end])
entries.append(contentsOf: page)
currentPage += 1
hasMore = end < filtered.count
}
private func filteredSeeds() -> [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..<count {
let user = users[i % users.count]
let resource = resources[i % resources.count]
let action = actions[i % actions.count]
let ago = TimeInterval(i) * -247 - Double.random(in: 0...180)
results.append(AuditEntry(
id: UUID(),
action: action,
userName: user.0,
userInitials: user.1,
resource: resource.0,
resourceType: resource.1,
detail: i % 4 == 0 ? "Fältet 'status' ändrades" : nil,
timestamp: Date(timeIntervalSinceNow: ago)
))
}
return results
}
}
// MARK: - Relative time
extension Date {
var relativeShort: String {
let diff = Date().timeIntervalSince(self)
switch diff {
case ..<60: return "Nu"
case ..<3600: return "\(Int(diff / 60)) min"
case ..<86400: return "\(Int(diff / 3600)) tim"
case ..<604800: return "\(Int(diff / 86400)) d"
default:
let f = DateFormatter()
f.dateFormat = "d MMM"
f.locale = Locale(identifier: "sv_SE")
return f.string(from: self)
}
}
}
// MARK: - AuditRow
struct AuditRow: View {
let entry: AuditEntry
var body: some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
Circle()
.fill(entry.action.color.opacity(0.12))
.frame(width: 40, height: 40)
Image(systemName: entry.action.symbol)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(entry.action.color)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(entry.userName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color(.label))
ActionBadge(action: entry.action)
Spacer(minLength: 0)
Text(entry.timestamp.relativeShort)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(Color(.tertiaryLabel))
}
HStack(spacing: 4) {
Image(systemName: resourceSymbol(for: entry.resourceType))
.font(.system(size: 11))
.foregroundStyle(Color(.secondaryLabel))
Text(entry.resource)
.font(.system(size: 13))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(1)
}
if let detail = entry.detail {
Text(detail)
.font(.system(size: 12))
.foregroundStyle(Color(.tertiaryLabel))
.lineLimit(1)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private func resourceSymbol(for type: String) -> 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<AuditFilter>, 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()
}