import SwiftUI /// Sortierung einer Übersichtstabelle. `column` ist "name", "any" oder eine Aufgaben-ID. struct OverviewSort: Equatable { var column = "name" var ascending = true } struct OverviewRow: Identifiable { let person: Person let any: String? let cells: [Cell] var id: String { person.id } enum Cell { case notAllowed, never, date(String) } } /// Zeilen eines Bereichs – auch vom Export genutzt, damit er dieselbe Sortierung zeigt. @MainActor func overviewRows(store: Store, group: TaskGroup, sort: OverviewSort) -> (tasks: [DutyTask], rows: [OverviewRow]) { let tasks = store.db.tasks.filter { $0.group == group } let ids = Set(tasks.map(\.id)) let people = store.db.persons.filter { $0.tasks.contains(where: ids.contains) } var rows = people.map { p in OverviewRow(person: p, any: store.lastAny(person: p.id), cells: tasks.map { t in guard p.may(t.id) else { return .notAllowed } return store.lastDate(person: p.id, task: t).map { .date($0) } ?? .never }) } let byName: (OverviewRow, OverviewRow) -> Bool = { $0.person.name.localizedCompare($1.person.name) == .orderedAscending } // "nie" zählt als am längsten her, "nicht freigegeben" steht immer am Ende. func key(_ r: OverviewRow) -> String? { if sort.column == "any" { return r.any ?? "0000" } guard let i = tasks.firstIndex(where: { $0.id == sort.column }) else { return nil } switch r.cells[i] { case .notAllowed: return nil case .never: return "0000" case .date(let d): return d } } rows.sort { a, b in if sort.column == "name" { return sort.ascending ? byName(a, b) : byName(b, a) } switch (key(a), key(b)) { case (nil, nil): return byName(a, b) case (nil, _): return false case (_, nil): return true case let (ka?, kb?): if ka == kb { return byName(a, b) } return sort.ascending ? ka < kb : ka > kb } } return (tasks, rows) } struct OverviewView: View { @EnvironmentObject var store: Store var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 24) { HStack(spacing: 16) { legend(Text("Datum"), "letzte Einteilung") legend(Text("nie").foregroundStyle(Theme.kohaku).bold(), "freigegeben, aber noch nie eingeteilt") legend(Text("–").foregroundStyle(Theme.faint), "nicht freigegeben") Spacer() Text("Spaltenkopf klicken = sortieren · Zelle klicken = eintragen") } .font(.caption) .foregroundStyle(Theme.muted) if store.db.persons.isEmpty { EmptyStart() } else { ForEach(TaskGroup.allCases) { OverviewSection(group: $0) } } } .padding(24) } } private func legend(_ symbol: Text, _ text: String) -> some View { HStack(spacing: 4) { symbol; Text("= \(text)") } } } struct EmptyStart: View { @EnvironmentObject var store: Store @EnvironmentObject var ui: UIState var body: some View { VStack(spacing: 14) { Image(systemName: "person.3.sequence") .font(.system(size: 44, weight: .light)) .foregroundStyle(Theme.tokiwa) Text("Noch niemand eingetragen.").font(.title2.weight(.semibold)) Text("Leg unter „Personen“ alle an, die mitmachen, und hak an, wer was übernehmen darf.") .foregroundStyle(Theme.muted) HStack { Button("Personen anlegen") { ui.tab = .persons }.buttonStyle(.borderedProminent) Button("Daten aus dem HTML-Tool laden …") { Exporter.importBackup(store: store, ui: ui) } } .padding(.top, 6) } .frame(maxWidth: .infinity) .padding(.vertical, 80) } } struct OverviewSection: View { let group: TaskGroup @EnvironmentObject var store: Store @EnvironmentObject var ui: UIState @AppStorage private var sortColumn: String @AppStorage private var sortAscending: Bool init(group: TaskGroup) { self.group = group _sortColumn = AppStorage(wrappedValue: "name", "sort.\(group.rawValue).column") _sortAscending = AppStorage(wrappedValue: true, "sort.\(group.rawValue).ascending") } private let nameWidth: CGFloat = 210 private let anyWidth: CGFloat = 100 private let cellWidth: CGFloat = 108 var body: some View { let sort = OverviewSort(column: sortColumn, ascending: sortAscending) let (tasks, rows) = overviewRows(store: store, group: group, sort: sort) let color = Theme.color(for: group) VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { Text(group.title).font(.headline) Text("\(rows.count) Personen").font(.subheadline).opacity(0.8) } .foregroundStyle(.white) .padding(.horizontal, 14).padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(color) ScrollView(.horizontal) { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 0) { header("Name", column: "name", width: nameWidth, alignment: .leading) header("Zuletzt (irgendeine)", column: "any", width: anyWidth) ForEach(tasks) { header($0.name, column: $0.id, width: cellWidth) } } .background(Theme.soft(for: group)) if rows.isEmpty { Text("Noch niemand freigegeben – unter „Personen“ Häkchen setzen.") .foregroundStyle(Theme.muted) .padding(14) } ForEach(rows) { row in HStack(spacing: 0) { Text(row.person.name) .fontWeight(.medium) .lineLimit(1) .padding(.horizontal, 12) .frame(width: nameWidth, alignment: .leading) Text(row.any.map(Day.display) ?? "nie") .monospacedDigit() .foregroundStyle(Theme.muted) .frame(width: anyWidth) ForEach(Array(zip(tasks, row.cells)), id: \.0.id) { task, cell in OverviewCell(cell: cell, width: cellWidth) { if task.partnerColumn { ui.newEntry(partner: row.person.id) } else { ui.newEntry(task: task.id, person: row.person.id) } } .help("\(task.name) · \(row.person.name)") } } .frame(height: 44) .overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) } } } } } .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.line)) } private func header(_ title: String, column: String, width: CGFloat, alignment: Alignment = .center) -> some View { Button { if sortColumn == column { sortAscending.toggle() } else { sortColumn = column; sortAscending = true } } label: { HStack(spacing: 3) { if sortColumn == column { Image(systemName: sortAscending ? "chevron.up" : "chevron.down") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Theme.color(for: group)) } Text(title) .font(.caption.weight(.semibold)) .multilineTextAlignment(alignment == .leading ? .leading : .center) .lineLimit(2) } .foregroundStyle(sortColumn == column ? Color.primary : Theme.muted) .padding(.horizontal, alignment == .leading ? 12 : 6) .frame(width: width, height: 46, alignment: alignment == .leading ? .bottomLeading : .bottom) .padding(.bottom, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(column == "name" ? "Nach Name sortieren" : "Sortieren: wer am längsten nicht dran war, steht oben") } } struct OverviewCell: View { let cell: OverviewRow.Cell let width: CGFloat let action: () -> Void @State private var hover = false var body: some View { switch cell { case .notAllowed: Text("–").foregroundStyle(Theme.faint).frame(width: width, height: 44) case .never: Button(action: action) { Text("nie").fontWeight(.medium).foregroundStyle(Theme.kohaku) .frame(width: width, height: 44) .background(hover ? Theme.kohaku.opacity(0.22) : Theme.kohakuSoft) .contentShape(Rectangle()) } .buttonStyle(.plain).onHover { hover = $0 } case .date(let d): Button(action: action) { VStack(spacing: 1) { Text(Day.display(d)).monospacedDigit() Text(Day.agoText(d)).font(.caption2).foregroundStyle(Theme.muted) } .frame(width: width, height: 44) .background(hover ? Color.primary.opacity(0.06) : .clear) .contentShape(Rectangle()) } .buttonStyle(.plain).onHover { hover = $0 } } } }