import SwiftUI // MARK: - SavingsView struct SavingsView: View { @State private var selectedTab = 0 var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { HStack { Text("Накопления").font(.title.bold()).foregroundColor(.white) Spacer() } .padding(.horizontal) .padding(.top, 16) .padding(.bottom, 12) Picker("", selection: $selectedTab) { Text("Обзор").tag(0) Text("Категории").tag(1) Text("Операции").tag(2) } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 12) switch selectedTab { case 0: SavingsOverviewTab2() case 1: SavingsCategoriesTab() default: SavingsOperationsTab() } } } } } // MARK: - SavingsOverviewTab2 struct SavingsOverviewTab2: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @State private var stats: SavingsStats? @State private var isLoading = true var monthlyDetails: [MonthlyPaymentDetail] { stats?.monthlyPaymentDetails ?? [] } var overdues: [OverduePayment] { stats?.overdues ?? [] } var hasOverdue: Bool { !overdues.isEmpty } var body: some View { ScrollView { VStack(spacing: 16) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { // Total Balance Card if let s = stats { VStack(spacing: 16) { VStack(spacing: 6) { Text("Общий баланс").font(.subheadline).foregroundColor(Color(hex: "8888aa")) Text(formatAmt(s.totalBalance ?? 0)) .font(.system(size: 36, weight: .bold)).foregroundColor(.white) } HStack { VStack(spacing: 4) { Text("Пополнения").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("+\(formatAmt(s.totalDeposits ?? 0))") .font(.callout.bold()).foregroundColor(Color(hex: "0D9488")) } Spacer() VStack(spacing: 4) { Text("Снятия").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("-\(formatAmt(s.totalWithdrawals ?? 0))") .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) } Spacer() VStack(spacing: 4) { Text("Категорий").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("\(s.categoriesCount ?? 0)") .font(.callout.bold()).foregroundColor(.white) } } } .padding(20) .background(LinearGradient(colors: [Color(hex: "1a1a3e"), Color(hex: "12122a")], startPoint: .topLeading, endPoint: .bottomTrailing)) .cornerRadius(20) .overlay(RoundedRectangle(cornerRadius: 20).stroke(Color(hex: "0D9488").opacity(0.3), lineWidth: 1)) .padding(.horizontal) } // Monthly payments from API if !monthlyDetails.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack { Text("Ежемесячные платежи") .font(.subheadline.bold()).foregroundColor(.white) Spacer() Text(formatAmt(stats?.monthlyPayments ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: "ffa502")) } .padding(.horizontal) ForEach(monthlyDetails) { detail in HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: "ffa502").opacity(0.15)).frame(width: 40, height: 40) Image(systemName: "calendar.badge.clock").foregroundColor(Color(hex: "ffa502")).font(.body) } VStack(alignment: .leading, spacing: 2) { Text(detail.categoryName).font(.callout).foregroundColor(.white) Text("\(detail.day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa")) } Spacer() Text(formatAmt(detail.amount)) .font(.callout.bold()).foregroundColor(Color(hex: "ffa502")) } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) .padding(.horizontal) } } } // Overdues — detailed list if hasOverdue { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(Color(hex: "ff4757")) Text("Просрочки") .font(.subheadline.bold()).foregroundColor(Color(hex: "ff4757")) Spacer() Text(formatAmt(stats?.overdueAmount ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) } .padding(.horizontal) ForEach(overdues) { overdue in HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: "ff4757").opacity(0.15)).frame(width: 40, height: 40) Image(systemName: "exclamationmark.circle.fill").foregroundColor(Color(hex: "ff4757")).font(.body) } VStack(alignment: .leading, spacing: 2) { Text(overdue.categoryName).font(.callout).foregroundColor(.white) HStack(spacing: 6) { Text(overdue.month) .font(.caption2.bold()) .foregroundColor(Color(hex: "ff4757")) .padding(.horizontal, 6).padding(.vertical, 2) .background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: "ff4757").opacity(0.15))) Text("\(overdue.daysOverdue) дн. просрочки") .font(.caption2).foregroundColor(Color(hex: "8888aa")) } } Spacer() Text(formatAmt(overdue.amount)) .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.06))) .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.2), lineWidth: 1)) .padding(.horizontal) } } } // Categories progress if !categories.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Прогресс по категориям") .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) ForEach(categories.filter { $0.isClosed != true }) { cat in SavingsCategoryCard(category: cat) } } } } Spacer(minLength: 80) } .padding(.top, 8) } .task { await load() } .refreshable { await load(refresh: true) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } async let cats = APIService.shared.getSavingsCategories(token: authManager.token) async let st = APIService.shared.getSavingsStats(token: authManager.token) categories = (try? await cats) ?? [] stats = try? await st isLoading = false } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - SavingsCategoriesTab struct SavingsCategoriesTab: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [SavingsCategory] = [] @State private var isLoading = true @State private var showAdd = false @State private var editingCategory: SavingsCategory? @State private var recurringPlansCategory: SavingsCategory? var active: [SavingsCategory] { categories.filter { $0.isClosed != true } } var closed: [SavingsCategory] { categories.filter { $0.isClosed == true } } var body: some View { ZStack(alignment: .bottomTrailing) { Group { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) Spacer() } else if active.isEmpty { VStack { EmptyState(icon: "building.columns", text: "Нет категорий"); Spacer() } } else { List { Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) { ForEach(active) { cat in SavingsCategoryRow(category: cat, showRecurringButton: cat.isRecurring == true) { recurringPlansCategory = cat } .listRowBackground(Color.clear) .listRowSeparator(.hidden) .onTapGesture { editingCategory = cat } } .onDelete { idx in let toDelete = idx.map { active[$0] } Task { for c in toDelete { try? await APIService.shared.deleteSavingsCategory(token: authManager.token, id: c.id) } await load(refresh: true) } } } if !closed.isEmpty { Section(header: Text("Закрытые").foregroundColor(Color(hex: "8888aa"))) { ForEach(closed) { cat in SavingsCategoryRow(category: cat) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } } } .listStyle(.plain) .scrollContentBackground(.hidden) } } .refreshable { await load(refresh: true) } Button(action: { showAdd = true }) { ZStack { Circle() .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 56, height: 56) .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) } } .padding(.bottom, 90) .padding(.trailing, 20) } .task { await load() } .sheet(isPresented: $showAdd) { AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingCategory) { cat in EditSavingsCategoryView(isPresented: .constant(true), category: cat) { await load(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } .sheet(item: $recurringPlansCategory) { cat in RecurringPlansView(category: cat) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } categories = (try? await APIService.shared.getSavingsCategories(token: authManager.token)) ?? [] isLoading = false } } // MARK: - SavingsCategoryRow struct SavingsCategoryRow: View { let category: SavingsCategory var showRecurringButton: Bool = false var onRecurringTap: (() -> Void)? = nil var body: some View { HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: category.colorHex).opacity(0.15)).frame(width: 44, height: 44) Image(systemName: category.icon).foregroundColor(Color(hex: category.colorHex)).font(.title3) } VStack(alignment: .leading, spacing: 3) { HStack(spacing: 4) { Text(category.typeEmoji) Text(category.name).font(.callout.bold()).foregroundColor(.white) } Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) } Spacer() if showRecurringButton { Button(action: { onRecurringTap?() }) { Image(systemName: "calendar.badge.clock") .foregroundColor(Color(hex: "0D9488")) .font(.callout) } .buttonStyle(.plain) } Text(formatAmt(category.currentAmount ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: category.colorHex)) } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) .padding(.horizontal) .padding(.vertical, 2) } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - AddSavingsCategoryView struct AddSavingsCategoryView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let onAdded: () async -> Void @State private var name = "" @State private var isDeposit = false @State private var isRecurring = false @State private var isAccount = false @State private var recurringAmount = "" @State private var interestRate = "" @State private var isLoading = false var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) Spacer() Text("Новая категория").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } }.disabled(name.isEmpty || isLoading) } .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 16) { fieldLabel("Название") { TextField("Например: На машину", text: $name) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } VStack(alignment: .leading, spacing: 8) { Label("Тип", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) HStack(spacing: 8) { TypeButton(label: "💰 Накопление", selected: !isDeposit && !isRecurring && !isAccount) { isDeposit = false; isRecurring = false; isAccount = false } TypeButton(label: "🏦 Вклад", selected: isDeposit) { isDeposit = true; isRecurring = false; isAccount = false } } HStack(spacing: 8) { TypeButton(label: "🔄 Регулярные", selected: isRecurring) { isDeposit = false; isRecurring = true; isAccount = false } TypeButton(label: "🏧 Счёт", selected: isAccount) { isDeposit = false; isRecurring = false; isAccount = true } } } if isRecurring { fieldLabel("Сумма / мес. (₽)") { TextField("0", text: $recurringAmount).keyboardType(.decimalPad) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } } if isDeposit { fieldLabel("Ставка (%)") { TextField("0.0", text: $interestRate).keyboardType(.decimalPad) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } } }.padding(20) } } } } @ViewBuilder func fieldLabel(_ label: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { Label(label, systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) content() } } func save() { isLoading = true Task { var params: [String: Any] = ["name": name, "is_deposit": isDeposit, "is_recurring": isRecurring, "is_account": isAccount] if isRecurring, let a = Double(recurringAmount) { params["recurring_amount"] = a } if isDeposit, let r = Double(interestRate) { params["interest_rate"] = r } if let body = try? JSONSerialization.data(withJSONObject: params) { try? await APIService.shared.createSavingsCategory(token: authManager.token, body: body) } await onAdded() await MainActor.run { isPresented = false } } } } struct TypeButton: View { let label: String let selected: Bool let action: () -> Void var body: some View { Button(action: action) { Text(label).font(.caption.bold()) .foregroundColor(selected ? .black : .white) .frame(maxWidth: .infinity).padding(.vertical, 10) .background(RoundedRectangle(cornerRadius: 10).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.07))) } } } // MARK: - SavingsOperationsTab struct SavingsOperationsTab: View { @EnvironmentObject var authManager: AuthManager @State private var transactions: [SavingsTransaction] = [] @State private var categories: [SavingsCategory] = [] @State private var selectedCategoryId: Int? = nil @State private var isLoading = true @State private var showAdd = false @State private var editingTransaction: SavingsTransaction? var filtered: [SavingsTransaction] { guard let cid = selectedCategoryId else { return transactions } return transactions.filter { $0.categoryId == cid } } var body: some View { ZStack(alignment: .bottomTrailing) { VStack(spacing: 0) { // Filter pills if !categories.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { FilterPill(label: "Все", selected: selectedCategoryId == nil) { selectedCategoryId = nil } ForEach(categories.filter { $0.isClosed != true }) { cat in FilterPill(label: cat.name, selected: selectedCategoryId == cat.id) { selectedCategoryId = cat.id } } } .padding(.horizontal) } .padding(.bottom, 8) } if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) Spacer() } else if filtered.isEmpty { VStack { EmptyState(icon: "arrow.left.arrow.right", text: "Нет операций"); Spacer() } } else { List { ForEach(filtered) { tx in SavingsTransactionRow2(transaction: tx) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .onTapGesture { editingTransaction = tx } } .onDelete { idx in let toDelete = idx.map { filtered[$0] } Task { for t in toDelete { try? await APIService.shared.deleteSavingsTransaction(token: authManager.token, id: t.id) } await load(refresh: true) } } } .listStyle(.plain) .scrollContentBackground(.hidden) } } .refreshable { await load(refresh: true) } Button(action: { showAdd = true }) { ZStack { Circle() .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 56, height: 56) .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) } } .padding(.bottom, 90) .padding(.trailing, 20) } .task { await load() } .sheet(isPresented: $showAdd) { AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingTransaction) { tx in EditSavingsTransactionView(isPresented: .constant(true), transaction: tx, categories: categories) { editingTransaction = nil await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } async let txs = APIService.shared.getSavingsTransactions(token: authManager.token, categoryId: selectedCategoryId) async let cats = APIService.shared.getSavingsCategories(token: authManager.token) transactions = (try? await txs) ?? [] categories = (try? await cats) ?? [] isLoading = false } } struct FilterPill: View { let label: String let selected: Bool let action: () -> Void var body: some View { Button(action: action) { Text(label).font(.caption.bold()) .foregroundColor(selected ? .black : .white) .padding(.horizontal, 14).padding(.vertical, 8) .background(RoundedRectangle(cornerRadius: 20).fill(selected ? Color(hex: "0D9488") : Color.white.opacity(0.08))) } } } struct SavingsTransactionRow2: View { let transaction: SavingsTransaction var body: some View { HStack(spacing: 12) { ZStack { Circle() .fill((transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: transaction.isDeposit ? "arrow.down.circle.fill" : "arrow.up.circle.fill") .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) } VStack(alignment: .leading, spacing: 2) { Text(transaction.categoryName ?? "Без категории").font(.callout).foregroundColor(.white) HStack(spacing: 6) { if let name = transaction.userName { Text(name).font(.caption).foregroundColor(Color(hex: "8888aa")) } Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa")) } if let desc = transaction.description, !desc.isEmpty { Text(desc).font(.caption2).foregroundColor(Color(hex: "8888aa")) } } Spacer() Text("\(transaction.isDeposit ? "+" : "-")\(formatAmt(transaction.amount))") .font(.callout.bold()) .foregroundColor(transaction.isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) .padding(.horizontal) .padding(.vertical, 2) } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - AddSavingsTransactionView struct AddSavingsTransactionView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let categories: [SavingsCategory] let onAdded: () async -> Void @State private var amount = "" @State private var description = "" @State private var type = "deposit" @State private var selectedCategoryId: Int? = nil @State private var date = Date() @State private var isLoading = false @State private var errorMessage: String? var isDeposit: Bool { type == "deposit" } var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) Spacer() Text("Новая операция").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } else { Text("Добавить").foregroundColor((amount.isEmpty || selectedCategoryId == nil) ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } }.disabled(amount.isEmpty || selectedCategoryId == nil || isLoading) } .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 20) { // Type toggle HStack(spacing: 0) { Button(action: { type = "deposit" }) { Text("Пополнение ↓") .font(.callout.bold()).foregroundColor(isDeposit ? .black : Color(hex: "0D9488")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(isDeposit ? Color(hex: "0D9488") : Color.clear) } Button(action: { type = "withdrawal" }) { Text("Снятие ↑") .font(.callout.bold()).foregroundColor(!isDeposit ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(!isDeposit ? Color(hex: "ff4757") : Color.clear) } } .background(Color.white.opacity(0.07)).cornerRadius(12) HStack { Text(isDeposit ? "+" : "−").font(.title.bold()).foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) TextField("0", text: $amount).keyboardType(.decimalPad) .font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center) Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa")) } .padding(20) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) VStack(alignment: .leading, spacing: 8) { Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) ForEach(categories.filter { $0.isClosed != true }) { cat in Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { HStack(spacing: 10) { Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) Text(cat.name).font(.callout).foregroundColor(.white) Spacer() if selectedCategoryId == cat.id { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) } } } VStack(alignment: .leading, spacing: 8) { Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) DatePicker("", selection: $date, displayedComponents: .date) .labelsHidden() .colorInvert() .colorMultiply(Color(hex: "0D9488")) } VStack(alignment: .leading, spacing: 8) { Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Комментарий...", text: $description) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } if let err = errorMessage { Text(err) .font(.caption).foregroundColor(Color(hex: "ff4757")) .padding(10) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) } }.padding(20) } } } } func save() { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), let cid = selectedCategoryId else { return } isLoading = true errorMessage = nil let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let dateStr = df.string(from: date) Task { do { let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description, date: dateStr) try await APIService.shared.createSavingsTransaction(token: authManager.token, request: req) await onAdded() await MainActor.run { isPresented = false } } catch { await MainActor.run { errorMessage = error.localizedDescription isLoading = false } } } } } // MARK: - SavingsCategoryCard struct SavingsCategoryCard: View { let category: SavingsCategory var body: some View { HStack(spacing: 14) { ZStack { Circle() .fill(Color(hex: category.colorHex).opacity(0.15)) .frame(width: 46, height: 46) Image(systemName: category.icon) .foregroundColor(Color(hex: category.colorHex)) .font(.title3) } VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text(category.typeEmoji) Text(category.name) .font(.callout.bold()) .foregroundColor(.white) } Text(category.typeLabel) .font(.caption2) .foregroundColor(Color(hex: "8888aa")) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text(formatAmt(category.currentAmount ?? 0)) .font(.callout.bold()) .foregroundColor(Color(hex: category.colorHex)) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(Color(hex: category.colorHex).opacity(0.15), lineWidth: 1) ) .padding(.horizontal) } func formatAmt(_ v: Double) -> String { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } // MARK: - EditSavingsTransactionView struct EditSavingsTransactionView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let transaction: SavingsTransaction let categories: [SavingsCategory] let onSaved: () async -> Void @State private var amount: String @State private var description: String @State private var type: String @State private var selectedCategoryId: Int? @State private var date: Date @State private var isLoading = false @State private var errorMessage: String? var isDeposit: Bool { type == "deposit" } init(isPresented: Binding, transaction: SavingsTransaction, categories: [SavingsCategory], onSaved: @escaping () async -> Void) { self._isPresented = isPresented self.transaction = transaction self.categories = categories self.onSaved = onSaved self._amount = State(initialValue: String(format: "%.0f", transaction.amount)) self._description = State(initialValue: transaction.description ?? "") self._type = State(initialValue: transaction.type) self._selectedCategoryId = State(initialValue: transaction.categoryId) let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let d = transaction.date.flatMap { df.date(from: String($0.prefix(10))) } ?? Date() self._date = State(initialValue: d) } var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa")) Spacer() Text("Редактировать").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } else { Text("Сохранить").foregroundColor(amount.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } }.disabled(amount.isEmpty || isLoading) } .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 20) { HStack(spacing: 0) { Button(action: { type = "deposit" }) { Text("Пополнение ↓").font(.callout.bold()) .foregroundColor(isDeposit ? .black : Color(hex: "0D9488")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(isDeposit ? Color(hex: "0D9488") : Color.clear) } Button(action: { type = "withdrawal" }) { Text("Снятие ↑").font(.callout.bold()) .foregroundColor(!isDeposit ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(!isDeposit ? Color(hex: "ff4757") : Color.clear) } } .background(Color.white.opacity(0.07)).cornerRadius(12) HStack { Text(isDeposit ? "+" : "−").font(.title.bold()) .foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) TextField("0", text: $amount).keyboardType(.decimalPad) .font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center) Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa")) } .padding(20) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) VStack(alignment: .leading, spacing: 8) { Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) DatePicker("", selection: $date, displayedComponents: .date) .labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488")) } VStack(alignment: .leading, spacing: 8) { Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) ForEach(categories.filter { $0.isClosed != true }) { cat in Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { HStack(spacing: 10) { Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) Text(cat.name).font(.callout).foregroundColor(.white) Spacer() if selectedCategoryId == cat.id { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) } } } VStack(alignment: .leading, spacing: 8) { Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Комментарий...", text: $description) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } if let err = errorMessage { Text(err) .font(.caption).foregroundColor(Color(hex: "ff4757")) .padding(10) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) } }.padding(20) } } } } func save() { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), let cid = selectedCategoryId else { return } isLoading = true errorMessage = nil let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let dateStr = df.string(from: date) Task { do { let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description, date: dateStr) try await APIService.shared.updateSavingsTransaction(token: authManager.token, id: transaction.id, request: req) await onSaved() await MainActor.run { isPresented = false } } catch { await MainActor.run { errorMessage = error.localizedDescription isLoading = false } } } } } // MARK: - RecurringPlansView struct RecurringPlansView: View { @EnvironmentObject var authManager: AuthManager let category: SavingsCategory @State private var plans: [SavingsRecurringPlan] = [] @State private var isLoading = true @State private var showAdd = false @State private var newAmount = "" @State private var newDay = "1" @State private var newEffective = Date() @State private var editingPlan: SavingsRecurringPlan? @State private var editAmount = "" @State private var editDay = "" var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) HStack { Spacer() Text("Регулярные платежи").font(.headline).foregroundColor(.white) Spacer() } .padding(.horizontal, 20).padding(.vertical, 16) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 12) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20) } else if plans.isEmpty && !showAdd { Text("Нет регулярных платежей").font(.callout).foregroundColor(Color(hex: "8888aa")) .padding(.top, 20) } else { ForEach(plans) { plan in HStack { VStack(alignment: .leading, spacing: 4) { Text(formatAmt(plan.amount)).font(.callout.bold()).foregroundColor(.white) if let day = plan.day { Text("Каждый \(day) день месяца").font(.caption).foregroundColor(Color(hex: "8888aa")) } if let eff = plan.effective { Text("С \(formatDate(eff))").font(.caption2).foregroundColor(Color(hex: "8888aa")) } } Spacer() Button(action: { editingPlan = plan editAmount = String(format: "%.0f", plan.amount) editDay = String(plan.day ?? 1) }) { Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa")) } Button(action: { Task { await deletePlan(plan) } }) { Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7)) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05))) .padding(.horizontal) } } if let plan = editingPlan { VStack(spacing: 10) { Text("Редактировать платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa")) HStack { TextField("Сумма", text: $editAmount).keyboardType(.decimalPad) .foregroundColor(.white).padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) Text("₽").foregroundColor(Color(hex: "8888aa")) TextField("День", text: $editDay).keyboardType(.numberPad) .foregroundColor(.white).frame(width: 60).padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) } HStack { Button("Отмена") { editingPlan = nil }.foregroundColor(Color(hex: "8888aa")) Spacer() Button("Сохранить") { Task { await updatePlan(plan) } } .foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07))) .padding(.horizontal) } if showAdd { VStack(spacing: 10) { Text("Новый платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa")) HStack { TextField("Сумма", text: $newAmount).keyboardType(.decimalPad) .foregroundColor(.white).padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) Text("₽").foregroundColor(Color(hex: "8888aa")) TextField("День", text: $newDay).keyboardType(.numberPad) .foregroundColor(.white).frame(width: 60).padding(10) .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) } HStack { Text("Начало").font(.caption).foregroundColor(Color(hex: "8888aa")) DatePicker("", selection: $newEffective, displayedComponents: .date) .labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488")) } HStack { Button("Отмена") { showAdd = false }.foregroundColor(Color(hex: "8888aa")) Spacer() Button("Добавить") { Task { await addPlan() } } .foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07))) .padding(.horizontal) } if !showAdd && editingPlan == nil { Button(action: { showAdd = true }) { Label("Добавить платёж", systemImage: "plus.circle") .foregroundColor(Color(hex: "0D9488")) .padding(14) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "0D9488").opacity(0.08))) } .padding(.horizontal) } Spacer(minLength: 40) } .padding(.top, 12) } } } .task { await load() } } func load() async { isLoading = true plans = (try? await APIService.shared.getRecurringPlans(token: authManager.token, categoryId: category.id)) ?? [] isLoading = false } func addPlan() async { guard let a = Double(newAmount.replacingOccurrences(of: ",", with: ".")) else { return } let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let effStr = df.string(from: newEffective) let req = CreateRecurringPlanRequest(effective: effStr, amount: a, day: Int(newDay)) if let plan = try? await APIService.shared.createRecurringPlan(token: authManager.token, categoryId: category.id, request: req) { await MainActor.run { plans.append(plan); showAdd = false; newAmount = ""; newDay = "1" } } } func updatePlan(_ plan: SavingsRecurringPlan) async { guard let a = Double(editAmount.replacingOccurrences(of: ",", with: ".")) else { return } let req = UpdateRecurringPlanRequest(effective: plan.effective, amount: a, day: Int(editDay)) if let updated = try? await APIService.shared.updateRecurringPlan(token: authManager.token, planId: plan.id, request: req) { await MainActor.run { if let idx = plans.firstIndex(where: { $0.id == plan.id }) { plans[idx] = updated } editingPlan = nil } } } func deletePlan(_ plan: SavingsRecurringPlan) async { try? await APIService.shared.deleteRecurringPlan(token: authManager.token, planId: plan.id) await MainActor.run { plans.removeAll { $0.id == plan.id } } } func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } func formatDate(_ s: String) -> String { let parts = s.prefix(10).split(separator: "-") guard parts.count == 3 else { return String(s.prefix(10)) } return "\(parts[2]).\(parts[1]).\(parts[0])" } }