Files
pulse-mobile/PulseHealth/Views/Savings/SavingsView.swift

682 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - SavingsView
struct SavingsView: View {
@State private var selectedTab = 0
var body: some View {
ZStack {
Color(hex: "0a0a1a").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 recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } }
var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 }
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)
}
// Overdue block
if hasOverdue, let s = stats {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color(hex: "ff4757"))
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))")
.font(.caption).foregroundColor(.white.opacity(0.7))
}
Spacer()
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12)))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), 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)
}
}
}
// Monthly payments
if !recurringCategories.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Ежемесячные платежи")
.font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal)
ForEach(recurringCategories) { cat in
HStack(spacing: 12) {
ZStack {
Circle().fill(Color(hex: cat.colorHex).opacity(0.15)).frame(width: 40, height: 40)
Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body)
}
VStack(alignment: .leading, spacing: 2) {
Text(cat.name).font(.callout).foregroundColor(.white)
if let day = cat.recurringDay {
Text("\(day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa"))
}
}
Spacer()
Text(formatAmt(cat.recurringAmount ?? 0))
.font(.callout.bold()).foregroundColor(Color(hex: cat.colorHex))
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04)))
.padding(.horizontal)
}
}
}
}
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?
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)
.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: "0a0a1a"))
}
.sheet(item: ) { cat in
EditSavingsCategoryView(isPresented: .constant(true), category: cat) { await load(refresh: true) }
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationBackground(Color(hex: 0a0a1a))
}
}
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 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()
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: "0a0a1a").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<Content: View>(_ 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
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)
}
.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: "0a0a1a"))
}
}
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 isLoading = false
var isDeposit: Bool { type == "deposit" }
var body: some View {
ZStack {
Color(hex: "0a0a1a").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: "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)))
}
}.padding(20)
}
}
}
}
func save() {
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
let cid = selectedCategoryId else { return }
isLoading = true
Task {
let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description)
try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req)
await onAdded()
await MainActor.run { isPresented = 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)
}
}