|
|
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 }
|
|
|
}
|
|
|
}
|
|
|
}
|