Files
InvestmentTrackerApp/PortfolioJournal/Views/Settings/CSVMappingView.swift
T
alexandrev-tibco 7a18dd8360 1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad
- 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
2026-06-30 17:01:50 +02:00

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