7a18dd8360
- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed) con diálogo de propagación al editar: solo este / adelante / atrás / todos (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged) - 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos; ahora agrupa por mes calendario crudo - 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para recrear el StateObject al cambiar de selección - 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs arriba / detalle debajo - 145: card de contribución mensual en SourceDetailView - Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
361 lines
14 KiB
Swift
361 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct CSVMappingView: View {
|
|
let csvContent: String
|
|
let onComplete: (ImportService.CSVMappingConfig) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var config = ImportService.CSVMappingConfig()
|
|
@State private var preview: ImportService.CSVPreview?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// CSV Preview section
|
|
if let preview, let sample = preview.sampleRow(hasHeaderRow: config.hasHeaderRow) {
|
|
Section(String(localized: "csv_preview_section")) {
|
|
Toggle("Has Header Row", isOn: $config.hasHeaderRow)
|
|
.onChange(of: config.hasHeaderRow) { _, _ in resetMappings() }
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
ForEach(
|
|
Array(zip(
|
|
preview.headers(hasHeaderRow: config.hasHeaderRow),
|
|
sample
|
|
).enumerated()),
|
|
id: \.offset
|
|
) { _, pair in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(pair.0)
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundColor(.secondary)
|
|
Text(pair.1.isEmpty ? "—" : pair.1)
|
|
.font(.caption)
|
|
.lineLimit(2)
|
|
}
|
|
.frame(minWidth: 60, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Required fields
|
|
Section(String(localized: "csv_required_section")) {
|
|
MappingRow(
|
|
label: String(localized: "csv_field_source"),
|
|
index: $config.sourceIndex,
|
|
constant: $config.sourceConstant,
|
|
headers: currentHeaders,
|
|
supportsConstant: true,
|
|
supportsUseToday: false,
|
|
canSkip: false
|
|
)
|
|
|
|
MappingRow(
|
|
label: String(localized: "csv_field_value"),
|
|
index: $config.valueIndex,
|
|
constant: .constant(""),
|
|
headers: currentHeaders,
|
|
supportsConstant: false,
|
|
supportsUseToday: false,
|
|
canSkip: false
|
|
)
|
|
}
|
|
|
|
// Date section
|
|
Section {
|
|
MappingRow(
|
|
label: String(localized: "csv_field_date"),
|
|
index: $config.dateIndex,
|
|
constant: .constant(""),
|
|
headers: currentHeaders,
|
|
supportsConstant: false,
|
|
supportsUseToday: true,
|
|
canSkip: false
|
|
)
|
|
} header: {
|
|
Text("Date")
|
|
} footer: {
|
|
if config.dateIndex == ImportService.CSVMappingConfig.useToday {
|
|
Text(String(localized: "csv_no_date_hint"))
|
|
}
|
|
}
|
|
|
|
// Optional fields
|
|
Section(String(localized: "csv_optional_section")) {
|
|
MappingRowWithConstant(
|
|
label: String(localized: "csv_field_category"),
|
|
index: $config.categoryIndex,
|
|
constant: $config.categoryConstant,
|
|
headers: currentHeaders
|
|
)
|
|
|
|
MappingRow(
|
|
label: String(localized: "csv_field_contribution"),
|
|
index: $config.contributionIndex,
|
|
constant: .constant(""),
|
|
headers: currentHeaders,
|
|
supportsConstant: false,
|
|
supportsUseToday: false,
|
|
canSkip: true
|
|
)
|
|
|
|
MappingRow(
|
|
label: String(localized: "csv_field_notes"),
|
|
index: $config.notesIndex,
|
|
constant: .constant(""),
|
|
headers: currentHeaders,
|
|
supportsConstant: false,
|
|
supportsUseToday: false,
|
|
canSkip: true
|
|
)
|
|
}
|
|
}
|
|
.navigationTitle("Map Columns")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Import") {
|
|
onComplete(config)
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
.disabled(!config.isValid)
|
|
}
|
|
}
|
|
.onAppear {
|
|
let p = ImportService.shared.previewCSV(csvContent)
|
|
self.preview = p
|
|
autoDetectColumns(from: p)
|
|
}
|
|
}
|
|
.presentationDetents([.large])
|
|
}
|
|
|
|
private var currentHeaders: [String] {
|
|
preview?.headers(hasHeaderRow: config.hasHeaderRow) ?? []
|
|
}
|
|
|
|
private func resetMappings() {
|
|
guard let preview else { return }
|
|
autoDetectColumns(from: preview)
|
|
}
|
|
|
|
/// Try to auto-detect columns by matching common header names
|
|
private func autoDetectColumns(from preview: ImportService.CSVPreview) {
|
|
guard config.hasHeaderRow else { return }
|
|
let headers = preview.headers(hasHeaderRow: true)
|
|
let lower = headers.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
|
|
|
for (i, h) in lower.enumerated() {
|
|
if config.sourceIndex == ImportService.CSVMappingConfig.notMapped &&
|
|
(h == "source" || h == "name" || h == "fuente" || h == "nombre") {
|
|
config.sourceIndex = i
|
|
}
|
|
if config.valueIndex == ImportService.CSVMappingConfig.notMapped &&
|
|
(h.hasPrefix("value") || h == "amount" || h == "valor" || h == "importe") {
|
|
config.valueIndex = i
|
|
}
|
|
if config.dateIndex == ImportService.CSVMappingConfig.useToday &&
|
|
(h == "date" || h == "fecha" || h.hasPrefix("date")) {
|
|
config.dateIndex = i
|
|
}
|
|
if config.categoryIndex == ImportService.CSVMappingConfig.constant &&
|
|
(h == "category" || h == "categoría" || h == "categoria" || h == "type") {
|
|
config.categoryIndex = i
|
|
}
|
|
if config.contributionIndex == ImportService.CSVMappingConfig.notMapped &&
|
|
(h.hasPrefix("contribution") || h == "aportación" || h == "aportacion") {
|
|
config.contributionIndex = i
|
|
}
|
|
if config.notesIndex == ImportService.CSVMappingConfig.notMapped &&
|
|
(h == "notes" || h == "notas" || h == "note" || h == "comments") {
|
|
config.notesIndex = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Mapping Row (column or constant/useToday/skip)
|
|
|
|
private struct MappingRow: View {
|
|
let label: String
|
|
@Binding var index: Int
|
|
@Binding var constant: String
|
|
let headers: [String]
|
|
let supportsConstant: Bool
|
|
let supportsUseToday: Bool
|
|
let canSkip: Bool
|
|
|
|
private var selectionLabel: String {
|
|
if index == ImportService.CSVMappingConfig.useToday {
|
|
return String(localized: "csv_use_today")
|
|
}
|
|
if index == ImportService.CSVMappingConfig.constant {
|
|
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
|
|
}
|
|
if index == ImportService.CSVMappingConfig.notMapped {
|
|
return String(localized: "csv_no_column")
|
|
}
|
|
return headers[safe: index] ?? "Column \(index + 1)"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Menu {
|
|
// Column options
|
|
if !headers.isEmpty {
|
|
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
|
|
Button { index = i } label: {
|
|
Label(h, systemImage: index == i ? "checkmark" : "")
|
|
}
|
|
}
|
|
Divider()
|
|
}
|
|
|
|
if supportsUseToday {
|
|
Button {
|
|
index = ImportService.CSVMappingConfig.useToday
|
|
} label: {
|
|
Label(
|
|
String(localized: "csv_use_today"),
|
|
systemImage: index == ImportService.CSVMappingConfig.useToday ? "checkmark" : ""
|
|
)
|
|
}
|
|
}
|
|
|
|
if supportsConstant {
|
|
Button {
|
|
index = ImportService.CSVMappingConfig.constant
|
|
} label: {
|
|
Label(
|
|
String(localized: "csv_enter_value"),
|
|
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
|
|
)
|
|
}
|
|
}
|
|
|
|
if canSkip {
|
|
Button {
|
|
index = ImportService.CSVMappingConfig.notMapped
|
|
} label: {
|
|
Label(
|
|
String(localized: "csv_no_column"),
|
|
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(selectionLabel)
|
|
.font(.subheadline)
|
|
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
if index == ImportService.CSVMappingConfig.constant && supportsConstant {
|
|
TextField(String(localized: "csv_enter_value"), text: $constant)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mapping Row With Constant (column, constant value, or skip)
|
|
|
|
private struct MappingRowWithConstant: View {
|
|
let label: String
|
|
@Binding var index: Int
|
|
@Binding var constant: String
|
|
let headers: [String]
|
|
|
|
private var selectionLabel: String {
|
|
if index == ImportService.CSVMappingConfig.constant {
|
|
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
|
|
}
|
|
if index == ImportService.CSVMappingConfig.notMapped {
|
|
return String(localized: "csv_no_column")
|
|
}
|
|
return headers[safe: index] ?? "Column \(index + 1)"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(label)
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Menu {
|
|
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
|
|
Button { index = i } label: {
|
|
Label(h, systemImage: index == i ? "checkmark" : "")
|
|
}
|
|
}
|
|
Divider()
|
|
Button {
|
|
index = ImportService.CSVMappingConfig.constant
|
|
} label: {
|
|
Label(
|
|
String(localized: "csv_enter_value"),
|
|
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
|
|
)
|
|
}
|
|
Button {
|
|
index = ImportService.CSVMappingConfig.notMapped
|
|
} label: {
|
|
Label(
|
|
String(localized: "csv_no_column"),
|
|
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
|
|
)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(selectionLabel)
|
|
.font(.subheadline)
|
|
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
if index == ImportService.CSVMappingConfig.constant {
|
|
TextField(String(localized: "csv_enter_value"), text: $constant)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Array safe subscript
|
|
|
|
private extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
guard index >= 0, index < count else { return nil }
|
|
return self[index]
|
|
}
|
|
}
|