232 lines
12 KiB
Swift
232 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|