Files
reimburse/reimburse/MatchedPage.swift
T
2026-09-16 15:04:47 +08:00

232 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct MatchedPage: View {
@EnvironmentObject var store: WorkspaceStore
@State private var selectedGroup = "全部"
@State private var pptSelection = MatchedPPTSelection()
private var selectedMatches: [MatchGroup] {
store.state.matches.filter { pptSelection.ids.contains($0.id) }
}
private var groups: [String] {
["全部"] + Array(Set(store.state.matches.map(\.category))).sorted()
}
var body: some View {
VStack(spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("已核对材料").font(.title2.bold())
Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0)
Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty)
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up")
}
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
}
if store.state.matches.isEmpty {
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
} else {
Picker("分组", selection: $selectedGroup) {
ForEach(groups, id: \.self) { group in
Text(group == "全部" ? "全部(\(store.state.matches.count)" : "\(group)\(count(for: group))")
.tag(group)
}
}
.pickerStyle(.segmented)
.onChange(of: groups) { _, available in
if !available.contains(selectedGroup) { selectedGroup = "全部" }
}
MatchGroupPage(
title: selectedGroup,
matches: matches(for: selectedGroup),
baseIndex: baseIndex(for: selectedGroup),
classified: $store.classified,
selection: $pptSelection
)
HStack {
Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
.monospacedDigit()
Spacer()
Text("其中 \(selectedMatches.filter { selectedGroup != "全部" && $0.category != selectedGroup }.count) 组在其他分组")
.foregroundStyle(.secondary)
Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty)
}.font(.callout)
Text("切换分组保留勾选;PPT 仅导出所选组的完整发票与付款材料。行程 Excel 和个人报销单仍使用全部已核对材料。")
.font(.caption).foregroundStyle(.secondary)
}
}.padding(28).disabled(store.busy)
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in
pptSelection.retain(identifiers)
}
}
private func matches(for group: String) -> [MatchGroup] {
let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group }
return matches
}
private func count(for group: String) -> Int {
matches(for: group).count
}
private func baseIndex(for group: String) -> Int {
guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 }
return first + 1
}
}
struct MatchGroupPage: View {
@EnvironmentObject var store: WorkspaceStore
let title: String
let matches: [MatchGroup]
let baseIndex: Int
@Binding var classified: Bool
@Binding var selection: MatchedPPTSelection
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Toggle("全选当前分组", isOn: Binding(
get: { selection.containsAll(matches) },
set: { selection.setVisible(matches, selected: $0) }
))
.toggleStyle(.checkbox)
Text(title).font(.headline)
Text("\(matches.count) 组 · 已选 \(matches.filter { selection.ids.contains($0.id) }.count)")
.font(.caption).foregroundStyle(.secondary)
Spacer()
Text("按发票日期排列").font(.caption).foregroundStyle(.secondary)
}
if matches.isEmpty {
EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。")
} else {
Table(matches) {
TableColumn("选择") { match in
Toggle("选择第 \(index(of: match)) 组", isOn: Binding(
get: { selection.ids.contains(match.id) },
set: { selected in
if selected { selection.ids.insert(match.id) }
else { selection.ids.remove(match.id) }
}
))
.toggleStyle(.checkbox).labelsHidden()
}.width(48)
TableColumn("序号") { match in
Text(String(format: "%02d", index(of: match))).monospacedDigit().foregroundStyle(.secondary)
}.width(min: 48, ideal: 56, max: 64)
TableColumn("发票") { match in
MaterialCell(items: match.invoices, store: store)
}.width(min: 170, ideal: 235)
TableColumn("付款截图") { match in
MaterialCell(items: match.payments, store: store)
}.width(min: 170, ideal: 235)
TableColumn("金额") { match in
VStack(alignment: .trailing, spacing: 3) {
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)).monospacedDigit()
Text("付 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
}.frame(maxWidth: .infinity, alignment: .trailing)
}.width(min: 115, ideal: 135)
TableColumn("日期") { match in
Text(match.date == "9999-12-31" ? "未识别" : match.date).monospacedDigit()
}.width(min: 92, ideal: 105)
TableColumn("匹配") { match in
Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
.foregroundStyle(match.matchType == "auto" ? .teal : .blue)
}.width(min: 90, ideal: 105)
TableColumn("操作") { match in
HStack(spacing: 7) {
if classified {
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
ForEach(expenseCategories, id: \.self) { Text($0) }
}.labelsHidden().frame(width: 95)
}
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }
.buttonStyle(.borderless).help("撤销本组配对")
}
}.width(min: classified ? 135 : 48, ideal: classified ? 145 : 60)
}
.frame(minHeight: 330)
}
}
}
private func index(of match: MatchGroup) -> Int {
guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex }
return baseIndex + index
}
}
struct MaterialCell: View {
let items: [Material]
@ObservedObject var store: WorkspaceStore
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("\(items.count) 份").font(.caption.weight(.medium))
ForEach(items.prefix(2)) { item in
Button(item.name) { store.preview = item }
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
}
if items.count > 2 {
Text("还有 \(items.count - 2) 份").font(.caption).foregroundStyle(.secondary)
}
}.padding(.vertical, 4)
}
}
struct ExpenseSheet: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("个人报销单").font(.title2.bold())
Text("使用原报销模板 · 每页 13 条明细 · 金额按已核对发票计算").font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
ScrollView {
VStack(spacing: 12) {
ForEach(Array(store.state.matches.enumerated()), id: \.element.id) { index, match in
HStack(spacing: 16) {
Text(String(format: "%02d", index + 1)).font(.callout.monospacedDigit()).foregroundStyle(.secondary).frame(width: 30)
Text("\(match.category) · \(match.invoices.count) 张").frame(width: 120, alignment: .leading)
Text(currency(match.expenseAmount)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing)
TextField("支出项目 / 用途", text: Binding(get: { store.purposes[match.id] ?? "" }, set: { store.purposes[match.id] = $0 }))
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
}
}
Divider()
HStack {
Text("签字岗位").font(.headline)
Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary)
Spacer()
Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6)
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(store.signatures.indices, id: \.self) { index in
HStack {
TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } }))
Button { store.signatures.remove(at: index) } label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless)
}
}
}
Divider()
HStack {
Text("保留模板中的公司、项目、收款信息和计算公式。").font(.caption).foregroundStyle(.secondary)
Spacer()
Button("取消") { dismiss() }.keyboardShortcut(.cancelAction)
Button("生成报销单") { store.export(.expense) }.buttonStyle(.borderedProminent)
}
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder).disabled(store.busy)
.interactiveDismissDisabled(store.busy)
}
}