You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

243 lines
9.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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

Powered by TurnKey Linux.