Add order tail flows and migrate queue, message, settlement, and audit modules.

Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,41 @@
//
// ScenicSettlementAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
///
@MainActor
protocol ScenicSettlementServing {
///
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws
}
@MainActor
@Observable
/// API
final class ScenicSettlementAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/scenic-settlement/submit",
body: request
)
)
}
}
extension ScenicSettlementAPI: ScenicSettlementServing {}

View File

@ -0,0 +1,28 @@
//
// ScenicSettlementModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicSettlementOption: Equatable, Identifiable {
let id: Int
let name: String
var selected: Bool
}
///
struct ScenicSettlementSubmitRequest: Encodable, Equatable {
let scenicId: Int
let applyAmount: String
let applyRemark: String
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case applyAmount = "apply_amount"
case applyRemark = "apply_remark"
}
}

View File

@ -0,0 +1,23 @@
# 景区结算模块
## 模块职责
`Features/ScenicSettlement` 承接首页 `scenic_settlement``scenic_settlement_review` 权限入口,负责景区结算申请提交和结算相关审核记录展示。
## 结算申请
`ScenicSettlementViewModel` 从账号上下文读取已有景区,并通过 `ScenicPermissionAPI.scenicListAll` 加载全部可申请景区,排除已开通景区后供用户多选。
提交时校验金额必填、正数且最多两位小数,再按景区 ID 升序逐条调用 `/api/yf-handset-app/photog/scenic-settlement/submit`。全部成功后清空已选景区、金额和备注;任一请求失败时保留当前表单并提示错误。
## 结算审核
`ScenicSettlementReviewViewModel` 复用景区申请记录和权限申请记录接口:
- `scenicApplicationPendingAll`
- `roleApplyAll`
两个通道独立容错。单通道失败时展示另一通道数据并提示失败;双通道失败时展示整页失败和重新加载入口。
## 边界
旧 Android 工程中的景区结算仍是本地 mock 数据。本模块以旧 iOS 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。

View File

@ -0,0 +1,264 @@
//
// ScenicSettlementViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
import SwiftUI
@MainActor
@Observable
/// ViewModel
final class ScenicSettlementViewModel {
var existingScenics: [BusinessScope] = []
var options: [ScenicSettlementOption] = []
var amountText = ""
var remarkText = ""
var isLoading = false
var isSubmitting = false
var loadFailed = false
var loadFailureReason: String?
var message: String?
private var selectedIds = Set<Int>()
///
var selectedCount: Int {
selectedIds.count
}
/// ID使
var selectedScenicIds: Set<Int> {
selectedIds
}
///
var amountValidation: ScenicSettlementAmountValidation {
let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return .empty }
guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else {
return .invalidFormat
}
guard let decimal = Decimal(string: trimmed), decimal > 0 else {
return .notPositive
}
return .valid
}
///
var canSubmit: Bool {
!isSubmitting && !selectedIds.isEmpty && amountValidation == .valid
}
///
func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async {
isLoading = true
loadFailed = false
loadFailureReason = nil
self.existingScenics = existingScenics
defer { isLoading = false }
do {
let all = try await api.scenicListAll().list
let existingIds = Set(existingScenics.map(\.id))
options = all
.filter { !existingIds.contains($0.id) }
.map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) }
} catch {
options = []
selectedIds.removeAll()
loadFailed = true
loadFailureReason = error.localizedDescription
message = "可申请景区加载失败,请重试"
}
}
///
func toggleScenic(id: Int) {
guard let index = options.firstIndex(where: { $0.id == id }) else { return }
options[index].selected.toggle()
if options[index].selected {
selectedIds.insert(id)
} else {
selectedIds.remove(id)
}
}
/// ID
func submit(api: any ScenicSettlementServing) async -> Bool {
guard !isSubmitting else { return false }
guard canSubmit else {
message = validationMessage
return false
}
isSubmitting = true
defer { isSubmitting = false }
let amount = normalizedAmount
let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines)
do {
for scenicId in selectedIds.sorted() {
try await api.scenicSettlementSubmit(
ScenicSettlementSubmitRequest(
scenicId: scenicId,
applyAmount: amount,
applyRemark: remark
)
)
}
selectedIds.removeAll()
options = options.map { option in
ScenicSettlementOption(id: option.id, name: option.name, selected: false)
}
amountText = ""
remarkText = ""
message = "提交成功,等待审核"
return true
} catch {
message = error.localizedDescription
return false
}
}
private var normalizedAmount: String {
let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue)
}
private var validationMessage: String {
if selectedIds.isEmpty { return "请先选择景区" }
switch amountValidation {
case .empty:
return "请填写结算金额"
case .invalidFormat:
return "金额格式错误,最多支持两位小数"
case .notPositive:
return "金额需大于 0"
case .valid:
return ""
}
}
}
///
enum ScenicSettlementAmountValidation: Equatable {
case empty
case invalidFormat
case notPositive
case valid
///
var badgeText: String {
switch self {
case .empty: "待填写金额"
case .invalidFormat: "金额格式错误"
case .notPositive: "金额需大于 0"
case .valid: "金额有效"
}
}
///
var badgeColor: Color {
switch self {
case .empty, .notPositive:
return AppDesign.warning
case .invalidFormat:
return Color(hex: 0xDC2626)
case .valid:
return AppDesign.success
}
}
}
@MainActor
@Observable
/// ViewModel
final class ScenicSettlementReviewViewModel {
var scenicApplications: [ScenicApplicationPendingResponse] = []
var roleApplications: [RoleApplyPendingResponse] = []
var isLoading = false
var message: String?
var loadFailedAll = false
var scenicLoadFailed = false
var roleLoadFailed = false
///
var pendingCount: Int {
scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count
}
///
func load(api: any ScenicPermissionServing) async {
isLoading = true
loadFailedAll = false
scenicLoadFailed = false
roleLoadFailed = false
message = nil
defer { isLoading = false }
var errors: [String] = []
var scenicLoaded = false
var roleLoaded = false
do {
scenicApplications = try await api.scenicApplicationPendingAll().items
scenicLoaded = true
} catch {
scenicApplications = []
scenicLoadFailed = true
errors.append("景区申请记录加载失败")
}
do {
roleApplications = try await api.roleApplyAll()
roleLoaded = true
} catch {
roleApplications = []
roleLoadFailed = true
errors.append("权限申请记录加载失败")
}
loadFailedAll = !scenicLoaded && !roleLoaded
if !errors.isEmpty {
message = errors.joined(separator: "")
}
}
///
func statusText(_ status: Int, fallback: String = "") -> String {
let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
switch status {
case 1: return "待审核"
case 2: return "已通过"
case 3: return "已驳回"
case 9: return "已取消"
default: return "未知"
}
}
///
func statusIcon(_ status: Int) -> String {
switch status {
case 2: return "checkmark.circle.fill"
case 3: return "xmark.octagon.fill"
case 1: return "clock.badge.exclamationmark.fill"
default: return "questionmark.circle.fill"
}
}
///
func statusColor(_ status: Int) -> Color {
switch status {
case 2: return AppDesign.success
case 3: return Color(hex: 0xDC2626)
case 1: return AppDesign.warning
default: return AppDesign.textSecondary
}
}
}

View File

@ -0,0 +1,432 @@
//
// ScenicSettlementViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import SwiftUI
///
struct ScenicSettlementView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@Environment(ScenicSettlementAPI.self) private var scenicSettlementAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = ScenicSettlementViewModel()
@State private var showScenicApplication = false
var body: some View {
VStack(spacing: 0) {
NewScenicBanner {
showScenicApplication = true
}
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
existingScenicsSection
selectableScenicsSection
settlementInfoSection
}
.padding(AppMetrics.Spacing.medium)
}
submitButton
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("景区结算")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes)
}
.refreshable {
await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes)
}
.sheet(isPresented: $showScenicApplication) {
NavigationStack {
ScenicApplicationView()
}
}
.onChange(of: viewModel.message) { _, message in
guard let message else { return }
toastCenter.show(message)
viewModel.message = nil
}
}
private var existingScenicsSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("已有景区")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
if viewModel.existingScenics.isEmpty {
Text("暂无已开通景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.existingScenics) { scenic in
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(AppDesign.success)
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textPrimary)
Spacer(minLength: 0)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private var selectableScenicsSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("可申请结算景区")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Spacer()
formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary)
}
if viewModel.isLoading && viewModel.options.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 120)
} else if viewModel.loadFailed {
VStack(spacing: AppMetrics.Spacing.xSmall) {
ContentUnavailableView(
"可申请景区加载失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, minHeight: 160)
} else if viewModel.options.isEmpty {
Text("暂无可申请结算景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.options) { option in
Button {
viewModel.toggleScenic(id: option.id)
} label: {
HStack {
Text(option.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(option.selected ? AppDesign.primary : AppDesign.textPrimary)
Spacer()
Image(systemName: option.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(option.selected ? AppDesign.primary : Color(hex: 0xCBD5E1))
}
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(option.selected ? AppDesign.primarySoft : Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private var settlementInfoSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("结算信息")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary)
formBadge(viewModel.amountValidation.badgeText, tint: viewModel.amountValidation.badgeColor)
}
TextField("申请金额(必填)", text: Binding(
get: { viewModel.amountText },
set: { viewModel.amountText = $0 }
))
.keyboardType(.decimalPad)
.textFieldStyle(.plain)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(minHeight: 44)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
TextField("备注信息(可选)", text: Binding(
get: { viewModel.remarkText },
set: { viewModel.remarkText = $0 }
), axis: .vertical)
.lineLimit(2...4)
.textFieldStyle(.plain)
.padding(AppMetrics.Spacing.small)
.frame(minHeight: 86, alignment: .topLeading)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private var submitButton: some View {
Button(viewModel.isSubmitting ? "提交中..." : "提交审核") {
Task { _ = await viewModel.submit(api: scenicSettlementAPI) }
}
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
.buttonStyle(.plain)
.disabled(!viewModel.canSubmit)
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
private func formBadge(_ text: String, tint: Color) -> some View {
Text(text)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(tint)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 22)
.background(tint.opacity(0.12), in: Capsule())
}
}
///
struct ScenicSettlementReviewView: View {
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = ScenicSettlementReviewViewModel()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
if viewModel.loadFailedAll {
fullFailureView
} else {
reviewSummary
scenicSection
roleSection
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("结算审核")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.load(api: scenicPermissionAPI)
}
.refreshable {
await viewModel.load(api: scenicPermissionAPI)
}
.onChange(of: viewModel.message) { _, message in
guard let message else { return }
toastCenter.show(message)
viewModel.message = nil
}
}
private var fullFailureView: some View {
VStack(spacing: AppMetrics.Spacing.small) {
ContentUnavailableView(
"审核记录加载失败",
systemImage: "exclamationmark.triangle",
description: Text("景区申请与权限申请均未加载成功,请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.load(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 34)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, minHeight: 260)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private var reviewSummary: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
summaryCard("景区申请", "\(viewModel.scenicApplications.count)")
summaryCard("权限申请", "\(viewModel.roleApplications.count)")
summaryCard("待审核", "\(viewModel.pendingCount)")
}
}
private var scenicSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
sectionHeader("景区申请记录", count: viewModel.scenicApplications.count, icon: "mountain.2.fill")
if viewModel.scenicLoadFailed {
sectionFailureRow("景区申请记录加载失败")
} else if viewModel.scenicApplications.isEmpty {
Text("暂无景区申请记录")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.scenicApplications) { item in
reviewRow(
title: item.scenicName,
code: item.code,
subtitle: item.createdAt,
status: viewModel.statusText(item.status),
status: item.status
)
}
}
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private var roleSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
sectionHeader("权限申请记录", count: viewModel.roleApplications.count, icon: "person.badge.shield.checkmark.fill")
if viewModel.roleLoadFailed {
sectionFailureRow("权限申请记录加载失败")
} else if viewModel.roleApplications.isEmpty {
Text("暂无权限申请记录")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.roleApplications) { item in
reviewRow(
title: "\(item.roleName) · \(scenicNames(item.scenicList))",
code: item.code,
subtitle: item.createdAt,
status: viewModel.statusText(item.status, fallback: item.statusLabel),
status: item.status
)
}
}
}
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func sectionFailureRow(_ text: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(AppDesign.warning)
Text(text)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Spacer(minLength: 0)
Button("重试") {
Task { await viewModel.load(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.buttonStyle(.plain)
}
.padding(AppMetrics.Spacing.xSmall)
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
}
private func reviewRow(title: String, code: String, subtitle: String, status statusText: String, status: Int) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Label("申请编号:\(code.settlementDash)", systemImage: "number.square.fill")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 22)
.background(AppDesign.primarySoft, in: Capsule())
HStack {
Label(statusText, systemImage: viewModel.statusIcon(status))
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(viewModel.statusColor(status))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 22)
.background(viewModel.statusColor(status).opacity(0.12), in: Capsule())
Spacer(minLength: 0)
Label(subtitle.settlementDash, systemImage: "clock")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
.padding(AppMetrics.Spacing.xSmall)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
}
private func summaryCard(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func sectionHeader(_ title: String, count: Int, icon: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text("\(count)")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 22)
.background(AppDesign.primarySoft, in: Capsule())
}
}
private func scenicNames(_ items: [RoleApplyScenicItem]) -> String {
let names = items.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return names.isEmpty ? "--" : names.joined(separator: "")
}
}
private struct NewScenicBanner: View {
let onApplyClick: () -> Void
var body: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "building.2.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(hex: 0xFF7B00))
Text("没有找到心仪的景区")
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(Color(hex: 0xFF7B00))
.lineLimit(1)
.minimumScaleFactor(0.85)
Spacer()
Button(action: onApplyClick) {
HStack(spacing: 4) {
Text("申请平台开通新景区")
.lineLimit(1)
.minimumScaleFactor(0.8)
Image(systemName: "chevron.right")
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(Color(hex: 0xFF7B00))
}
.buttonStyle(.plain)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.vertical, AppMetrics.Spacing.small)
.background(Color(hex: 0xFFF0E2))
}
}
private extension String {
var settlementDash: String {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "--" : trimmed
}
}