import SwiftUI import Charts // MARK: - FinanceView struct FinanceView: View { @EnvironmentObject var authManager: AuthManager @State private var selectedTab = 0 @State private var selectedMonth = Calendar.current.component(.month, from: Date()) @State private var selectedYear = Calendar.current.component(.year, from: Date()) var body: some View { ZStack { Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { // Header with month picker HStack { Text("Финансы").font(.title.bold()).foregroundColor(.white) Spacer() HStack(spacing: 8) { Button(action: { prevMonth() }) { Image(systemName: "chevron.left").foregroundColor(Color(hex: "8888aa")) } Text(monthLabel()) .font(.subheadline).foregroundColor(.white) .frame(minWidth: 80) Button(action: { nextMonth() }) { Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")) } } } .padding(.horizontal) .padding(.top, 16) .padding(.bottom, 12) Picker("", selection: $selectedTab) { Text("Обзор").tag(0) Text("Транзакции").tag(1) Text("Аналитика").tag(2) Text("Категории").tag(3) } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 12) switch selectedTab { case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear) case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear) case 2: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear) default: FinanceCategoriesTab() } } } } func monthLabel() -> String { let df = DateFormatter() df.dateFormat = "LLLL yyyy" df.locale = Locale(identifier: "ru_RU") var comps = DateComponents(); comps.month = selectedMonth; comps.year = selectedYear if let d = Calendar.current.date(from: comps) { return df.string(from: d) } return "\(selectedMonth)/\(selectedYear)" } func prevMonth() { if selectedMonth == 1 { selectedMonth = 12; selectedYear -= 1 } else { selectedMonth -= 1 } } func nextMonth() { if selectedMonth == 12 { selectedMonth = 1; selectedYear += 1 } else { selectedMonth += 1 } } } // MARK: - FinanceOverviewTab struct FinanceOverviewTab: View { @EnvironmentObject var authManager: AuthManager let month: Int let year: Int @State private var summary: FinanceSummary? @State private var categories: [FinanceCategory] = [] @State private var isLoading = true var expenseByCategory: [CategorySpend] { (summary?.byCategory ?? []).filter { ($0.total ?? 0) > 0 }.sorted { ($0.total ?? 0) > ($1.total ?? 0) } } var dailyPoints: [DailySpend] { summary?.daily ?? [] } var body: some View { ScrollView { VStack(spacing: 16) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { // Summary Card if let s = summary { FinanceSummaryCard2(summary: s) } // Top Expenses if !expenseByCategory.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Топ расходов").font(.subheadline.bold()).foregroundColor(.white) ForEach(expenseByCategory.prefix(5)) { cat in let total = summary?.totalExpense ?? 1 let pct = (cat.total ?? 0) / max(total, 1) VStack(spacing: 4) { HStack { Text(cat.emoji ?? "💸").font(.subheadline) Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white) Spacer() Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) Text("\(Int(pct * 100))%").font(.caption2).foregroundColor(Color(hex: "8888aa")) } GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.07)) RoundedRectangle(cornerRadius: 3) .fill(Color(hex: "ff4757").opacity(0.7)) .frame(width: geo.size.width * CGFloat(pct)) } } .frame(height: 5) } } } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) .padding(.horizontal) } // Pie Chart if expenseByCategory.count > 1 { VStack(alignment: .leading, spacing: 8) { Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white) Chart(expenseByCategory) { cat in SectorMark( angle: .value("Сумма", cat.total ?? 0), innerRadius: .ratio(0.55), angularInset: 2 ) .foregroundStyle(by: .value("Кат.", cat.categoryName ?? "—")) .cornerRadius(4) } .frame(height: 200) .chartForegroundStyleScale(range: Gradient(colors: [ Color(hex: "0D9488"), Color(hex: "6366f1"), Color(hex: "f59e0b"), Color(hex: "ec4899"), Color(hex: "14b8a6"), Color(hex: "8b5cf6") ])) } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) .padding(.horizontal) } // Daily Line Chart if !dailyPoints.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Ежедневные траты").font(.subheadline.bold()).foregroundColor(.white) let df: DateFormatter = { let d = DateFormatter(); d.dateFormat = "yyyy-MM-dd"; return d }() Chart(dailyPoints.compactMap { p -> (Date, Double)? in guard let d = df.date(from: p.date) else { return nil } return (d, p.expense ?? p.total ?? 0) }, id: \.0) { item in AreaMark(x: .value("День", item.0), y: .value("Сумма", item.1)) .foregroundStyle(LinearGradient(colors: [Color(hex: "ff4757").opacity(0.4), Color.clear], startPoint: .top, endPoint: .bottom)) LineMark(x: .value("День", item.0), y: .value("Сумма", item.1)) .foregroundStyle(Color(hex: "ff4757")) .lineStyle(StrokeStyle(lineWidth: 2)) } .chartXAxis { AxisMarks(values: .stride(by: .day, count: 5)) { _ in AxisValueLabel(format: .dateTime.day()).foregroundStyle(Color(hex: "8888aa")) AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) } } .chartYAxis { AxisMarks { v in AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) AxisValueLabel().foregroundStyle(Color(hex: "8888aa")) } } .frame(height: 140) } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) .padding(.horizontal) } } Spacer(minLength: 80) } .padding(.top, 8) } .task { await load() } .onChange(of: month) { Task { await load() } } .onChange(of: year) { Task { await load() } } .refreshable { await load(refresh: true) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } async let s = APIService.shared.getFinanceSummary(token: authManager.token, month: month, year: year) async let c = APIService.shared.getFinanceCategories(token: authManager.token) summary = try? await s categories = (try? await c) ?? [] isLoading = false } func formatAmt(_ v: Double) -> String { v >= 1000 ? String(format: "%.0f ₽", v) : String(format: "%.0f ₽", v) } } // MARK: - FinanceSummaryCard2 struct FinanceSummaryCard2: View { let summary: FinanceSummary var body: some View { VStack(spacing: 16) { VStack(spacing: 4) { Text("Баланс месяца").font(.subheadline).foregroundColor(Color(hex: "8888aa")) Text(formatAmt(summary.balance ?? 0)) .font(.system(size: 34, weight: .bold)) .foregroundColor((summary.balance ?? 0) >= 0 ? Color(hex: "0D9488") : Color(hex: "ff4757")) } HStack { VStack(spacing: 4) { Text("Доходы").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("+\(formatAmt(summary.totalIncome ?? 0))") .font(.callout.bold()).foregroundColor(Color(hex: "0D9488")) } Spacer() VStack(spacing: 4) { Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("-\(formatAmt(summary.totalExpense ?? 0))") .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) } Spacer() VStack(spacing: 4) { Text("Перенос").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("\(formatAmt(summary.carriedOver ?? 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) } func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } } // MARK: - FinanceTransactionsTab struct FinanceTransactionsTab: View { @EnvironmentObject var authManager: AuthManager let month: Int let year: Int @State private var transactions: [FinanceTransaction] = [] @State private var categories: [FinanceCategory] = [] @State private var isLoading = true @State private var showAdd = false @State private var editingTransaction: FinanceTransaction? var groupedByDay: [(key: String, value: [FinanceTransaction])] { let grouped = Dictionary(grouping: transactions) { $0.dateOnly } return grouped.sorted { $0.key > $1.key } } var body: some View { ZStack(alignment: .bottomTrailing) { Group { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) Spacer() } else if transactions.isEmpty { VStack { EmptyState(icon: "creditcard", text: "Нет транзакций"); Spacer() } } else { List { ForEach(groupedByDay, id: \.key) { section in Section(header: Text(formatSectionDate(section.key)) .font(.caption).foregroundColor(Color(hex: "8888aa")) ) { ForEach(section.value) { tx in FinanceTxRow(transaction: tx, categories: categories) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .onTapGesture { editingTransaction = tx } } .onDelete { idx in let toDelete = idx.map { section.value[$0] } Task { for tx in toDelete { try? await APIService.shared.deleteTransaction(token: authManager.token, id: tx.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() } .onChange(of: month) { Task { await load() } } .onChange(of: year) { Task { await load() } } .sheet(isPresented: $showAdd) { AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingTransaction) { tx in EditTransactionView(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 t = APIService.shared.getTransactions(token: authManager.token, month: month, year: year) async let c = APIService.shared.getFinanceCategories(token: authManager.token) transactions = (try? await t) ?? [] categories = (try? await c) ?? [] isLoading = false } func formatSectionDate(_ s: String) -> String { let parts = s.split(separator: "-") guard parts.count == 3 else { return s } return "\(parts[2]).\(parts[1]).\(parts[0])" } } // MARK: - FinanceTxRow struct FinanceTxRow: View { let transaction: FinanceTransaction let categories: [FinanceCategory] var cat: FinanceCategory? { categories.first { $0.id == transaction.categoryId } } var isIncome: Bool { transaction.type == "income" } var body: some View { HStack(spacing: 12) { ZStack { Circle() .fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12)) .frame(width: 40, height: 40) Text(cat?.emoji ?? (isIncome ? "💰" : "💸")).font(.title3) } VStack(alignment: .leading, spacing: 2) { Text(transaction.description ?? cat?.name ?? "Операция") .font(.callout).foregroundColor(.white) Text(transaction.dateFormatted).font(.caption2).foregroundColor(Color(hex: "8888aa")) } Spacer() Text("\(isIncome ? "+" : "-")\(formatAmt(transaction.amount))") .font(.callout.bold()) .foregroundColor(isIncome ? 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 { String(format: "%.0f ₽", v) } } // MARK: - FinanceAnalyticsTab struct FinanceAnalyticsTab: View { @EnvironmentObject var authManager: AuthManager let month: Int let year: Int @State private var analytics: FinanceAnalytics? @State private var isLoading = true var body: some View { ScrollView { VStack(spacing: 16) { if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else { // Bar chart by category if let cats = analytics?.byCategory, !cats.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Расходы по категориям").font(.subheadline.bold()).foregroundColor(.white) Chart(cats.sorted { ($0.total ?? 0) > ($1.total ?? 0) }.prefix(8).map { $0 }) { cat in BarMark( x: .value("Сумма", cat.total ?? 0), y: .value("Кат.", cat.categoryName ?? "—") ) .foregroundStyle(Color(hex: "ff4757")) .cornerRadius(4) .annotation(position: .trailing) { Text(formatAmt(cat.total ?? 0)).font(.caption2).foregroundColor(Color(hex: "8888aa")) } } .chartXAxis { AxisMarks { _ in AxisGridLine().foregroundStyle(Color.white.opacity(0.05)) } } .frame(height: 250) } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) .padding(.horizontal) } // Month comparison if let cur = analytics?.currentMonth, let prev = analytics?.previousMonth { MonthComparisonCard(current: cur, previous: prev) } if analytics?.byCategory == nil && analytics?.currentMonth == nil { EmptyState(icon: "chart.bar", text: "Нет данных для аналитики") } } Spacer(minLength: 80) } .padding(.top, 8) } .task { await load() } .onChange(of: month) { Task { await load() } } .onChange(of: year) { Task { await load() } } .refreshable { await load(refresh: true) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } analytics = try? await APIService.shared.getFinanceAnalytics(token: authManager.token, month: month, year: year) isLoading = false } func formatAmt(_ v: Double) -> String { v >= 1000 ? String(format: "%.0f", v) : String(format: "%.0f", v) } } // MARK: - MonthComparisonCard struct MonthComparisonCard: View { let current: FinanceSummary let previous: FinanceSummary var diff: Double { (current.totalExpense ?? 0) - (previous.totalExpense ?? 0) } var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Сравнение с прошлым месяцем").font(.subheadline.bold()).foregroundColor(.white) HStack { VStack(spacing: 4) { Text("Этот месяц").font(.caption).foregroundColor(Color(hex: "8888aa")) Text(formatAmt(current.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "ff4757")) } Spacer() VStack(spacing: 4) { Text("Прошлый").font(.caption).foregroundColor(Color(hex: "8888aa")) Text(formatAmt(previous.totalExpense ?? 0)).font(.headline.bold()).foregroundColor(Color(hex: "8888aa")) } Spacer() VStack(spacing: 4) { Text("Изменение").font(.caption).foregroundColor(Color(hex: "8888aa")) Text("\(diff > 0 ? "+" : "")\(formatAmt(diff))").font(.headline.bold()) .foregroundColor(diff > 0 ? Color(hex: "ff4757") : Color(hex: "0D9488")) } } } .padding(16) .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) .padding(.horizontal) } func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } } // MARK: - EditTransactionView struct EditTransactionView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let transaction: FinanceTransaction let categories: [FinanceCategory] 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 var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } } var isExpense: Bool { type == "expense" } init(isPresented: Binding, transaction: FinanceTransaction, categories: [FinanceCategory], 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: "00d4aa")).scaleEffect(0.8) } else { Text("Сохранить").foregroundColor(amount.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).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 = "expense" }) { Text("Расход").font(.callout.bold()) .foregroundColor(isExpense ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(isExpense ? Color(hex: "ff4757") : Color.clear) } Button(action: { type = "income" }) { Text("Доход").font(.callout.bold()) .foregroundColor(!isExpense ? .black : Color(hex: "00d4aa")) .frame(maxWidth: .infinity).padding(.vertical, 12) .background(!isExpense ? Color(hex: "00d4aa") : Color.clear) } } .background(Color.white.opacity(0.07)).cornerRadius(12) HStack { Text(isExpense ? "−" : "+").font(.title.bold()) .foregroundColor(isExpense ? Color(hex: "ff4757") : Color(hex: "00d4aa")) TextField("0", text: $amount).keyboardType(.decimalPad) .font(.system(size: 36, 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: "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))) } 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")) } if !filteredCategories.isEmpty { VStack(alignment: .leading, spacing: 8) { Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) { ForEach(filteredCategories) { cat in Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { HStack(spacing: 6) { Text(cat.emoji ?? "").font(.callout) Text(cat.name).font(.caption).lineLimit(1) } .foregroundColor(selectedCategoryId == cat.id ? .black : .white) .padding(.horizontal, 10).padding(.vertical, 8) .frame(maxWidth: .infinity) .background(RoundedRectangle(cornerRadius: 10) .fill(selectedCategoryId == cat.id ? Color(hex: "00d4aa") : Color.white.opacity(0.07))) } } } } } }.padding(20) } } } } func save() { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return } isLoading = true let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let dateStr = df.string(from: date) Task { let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId, description: description.isEmpty ? nil : description, type: type, date: dateStr) try? await APIService.shared.updateTransaction(token: authManager.token, id: transaction.id, request: req) await onSaved() await MainActor.run { isPresented = false } } } } // MARK: - FinanceCategoriesTab struct FinanceCategoriesTab: View { @EnvironmentObject var authManager: AuthManager @State private var categories: [FinanceCategory] = [] @State private var isLoading = true @State private var editingCategory: FinanceCategory? @State private var showAdd = false @State private var selectedType = "expense" var filtered: [FinanceCategory] { categories.filter { $0.type == selectedType } } var body: some View { ZStack(alignment: .bottomTrailing) { ScrollView { VStack(spacing: 12) { HStack(spacing: 0) { Button(action: { selectedType = "expense" }) { Text("Расходы").font(.callout.bold()) .foregroundColor(selectedType == "expense" ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 10) .background(selectedType == "expense" ? Color(hex: "ff4757") : Color.clear) } Button(action: { selectedType = "income" }) { Text("Доходы").font(.callout.bold()) .foregroundColor(selectedType == "income" ? .black : Color(hex: "0D9488")) .frame(maxWidth: .infinity).padding(.vertical, 10) .background(selectedType == "income" ? Color(hex: "0D9488") : Color.clear) } } .background(Color.white.opacity(0.07)).cornerRadius(12) .padding(.horizontal) if isLoading { ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) } else if filtered.isEmpty { EmptyState(icon: "tag", text: "Нет категорий") } else { ForEach(filtered) { cat in HStack(spacing: 12) { ZStack { Circle().fill(Color(hex: selectedType == "expense" ? "ff4757" : "0D9488").opacity(0.15)) .frame(width: 40, height: 40) Text(cat.emoji ?? (selectedType == "expense" ? "💸" : "💰")).font(.title3) } Text(cat.name).font(.callout).foregroundColor(.white) Spacer() Button(action: { editingCategory = cat }) { Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa")) } Button(action: { Task { await deleteCategory(cat) } }) { Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7)) } } .padding(14) .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05))) .padding(.horizontal) } } Spacer(minLength: 80) } .padding(.top, 8) } .task { await load() } .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) } .sheet(isPresented: $showAdd) { FinanceCategoryFormView(isPresented: $showAdd, category: nil, defaultType: selectedType) { await load(refresh: true) } .presentationDetents([.medium]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingCategory) { cat in FinanceCategoryFormView(isPresented: .constant(true), category: cat, defaultType: selectedType) { editingCategory = nil await load(refresh: true) } .presentationDetents([.medium]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "06060f")) } } func load(refresh: Bool = false) async { if !refresh { isLoading = true } categories = (try? await APIService.shared.getFinanceCategories(token: authManager.token)) ?? [] isLoading = false } func deleteCategory(_ cat: FinanceCategory) async { try? await APIService.shared.deleteFinanceCategory(token: authManager.token, id: cat.id) await load(refresh: true) } } // MARK: - FinanceCategoryFormView struct FinanceCategoryFormView: View { @Binding var isPresented: Bool @EnvironmentObject var authManager: AuthManager let category: FinanceCategory? let defaultType: String let onSaved: () async -> Void @State private var name = "" @State private var type: String @State private var emoji = "" @State private var isLoading = false let emojis = ["💸","💰","🏠","🍔","🚗","🎓","💊","✈️","👗","🎮","📱","🛒","⚡","🐾","🎵","💄","🍺","🎁","🏋️","📚"] init(isPresented: Binding, category: FinanceCategory?, defaultType: String, onSaved: @escaping () async -> Void) { self._isPresented = isPresented self.category = category self.defaultType = defaultType self.onSaved = onSaved self._name = State(initialValue: category?.name ?? "") self._type = State(initialValue: category?.type ?? defaultType) self._emoji = State(initialValue: category?.emoji ?? "") } 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(category == nil ? "Новая категория" : "Редактировать").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) { HStack(spacing: 0) { Button(action: { type = "expense" }) { Text("Расход").font(.callout.bold()) .foregroundColor(type == "expense" ? .black : Color(hex: "ff4757")) .frame(maxWidth: .infinity).padding(.vertical, 10) .background(type == "expense" ? Color(hex: "ff4757") : Color.clear) } Button(action: { type = "income" }) { Text("Доход").font(.callout.bold()) .foregroundColor(type == "income" ? .black : Color(hex: "0D9488")) .frame(maxWidth: .infinity).padding(.vertical, 10) .background(type == "income" ? Color(hex: "0D9488") : Color.clear) } } .background(Color.white.opacity(0.07)).cornerRadius(12) VStack(alignment: .leading, spacing: 8) { Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Название категории", text: $name) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } VStack(alignment: .leading, spacing: 8) { Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa")) LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) { ForEach(emojis, id: \.self) { e in Button(action: { emoji = e }) { Text(e).font(.title3) .frame(width: 44, height: 44) .background(Circle().fill(emoji == e ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05))) .overlay(Circle().stroke(emoji == e ? Color(hex: "0D9488") : Color.clear, lineWidth: 2)) } } } } }.padding(20) } } } } func save() { isLoading = true Task { let req = CreateFinanceCategoryRequest(name: name, type: type, emoji: emoji.isEmpty ? nil : emoji, budget: nil) if let cat = category { try? await APIService.shared.updateFinanceCategory(token: authManager.token, id: cat.id, request: req) } else { try? await APIService.shared.createFinanceCategory(token: authManager.token, request: req) } await onSaved() await MainActor.run { isPresented = false } } } }