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: "0a0a1a").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) } .pickerStyle(.segmented) .padding(.horizontal) .padding(.bottom, 12) switch selectedTab { case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear) case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear) default: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear) } } } } 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.icon ?? "💸").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) { _ in Task { await load() } } .onChange(of: year) { _ in 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 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) } .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) { _ in Task { await load() } } .onChange(of: year) { _ in Task { await load() } } .sheet(isPresented: $showAdd) { AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationBackground(Color(hex: "0a0a1a")) } } 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?.icon ?? (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) { _ in Task { await load() } } .onChange(of: year) { _ in 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) } }