From d27775ccf67e628c5ad7622eeafa1745058649e2 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Fri, 31 Oct 2025 19:01:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[REFAC]=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement.xcodeproj/project.pbxproj | 4 ++-- .../Core/DI/AppDependencies.swift | 2 +- .../Order/Data/Remote/API/OrderAPI.swift | 15 +++++++++++-- .../Data/Remote/DTO/OrderRequestDto.swift | 21 ++++++++++++++++++ .../Data/Repository/OrderRepositoryImpl.swift | 22 ++++++++++++++++--- .../Domain/Repository/OrderRepository.swift | 2 +- .../Domain/UseCase/CreateOrderUseCase.swift | 4 ++-- 7 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift diff --git a/SampoomManagement.xcodeproj/project.pbxproj b/SampoomManagement.xcodeproj/project.pbxproj index 1adaef9..d3251d5 100644 --- a/SampoomManagement.xcodeproj/project.pbxproj +++ b/SampoomManagement.xcodeproj/project.pbxproj @@ -282,7 +282,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -317,7 +317,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index 08d778f..b12d417 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -126,7 +126,7 @@ class AppDependencies { // Order orderAPI = OrderAPI(networkManager: networkManager) - orderRepository = OrderRepositoryImpl(api: orderAPI) + orderRepository = OrderRepositoryImpl(api: orderAPI, preferences: authPreferences) getOrderUseCase = GetOrderUseCase(repository: orderRepository) createOrderUseCase = CreateOrderUseCase(repository: orderRepository) getOrderDetailUseCase = GetOrderDetailUseCase(repository: orderRepository) diff --git a/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift index 9f1b0e3..12f087e 100644 --- a/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift +++ b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift @@ -27,10 +27,21 @@ class OrderAPI { } /// 주문 생성 - func createOrder() async throws -> [OrderDto] { + func createOrder(orderRequestDto: OrderRequestDto) async throws -> [OrderDto] { + let itemsParams: [[String: Any]] = orderRequestDto.items.map { item in + return [ + "code": item.code, + "quantity": item.quantity + ] + } + let parameters: [String: Any] = [ + "branch": orderRequestDto.branch, + "items": itemsParams + ] let response: APIResponse<[OrderDto]> = try await networkManager.request( - endpoint: "/agency/1/orders", + endpoint: "order/", method: .post, + parameters: parameters, responseType: [OrderDto].self ) guard response.success else { throw NetworkError.serverError(response.status) } diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift new file mode 100644 index 0000000..d05743a --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift @@ -0,0 +1,21 @@ +// +// OrderRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/31/25. +// + +import Foundation + +/// 주문 생성 요청 DTO +struct OrderRequestDto: Codable { + let branch: String + let items: [OrderItems] +} + +struct OrderItems: Codable { + let code: String + let quantity: Int64 +} + + diff --git a/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift index 6a064a1..8e0392e 100644 --- a/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift +++ b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift @@ -9,9 +9,11 @@ import Foundation class OrderRepositoryImpl: OrderRepository { private let api: OrderAPI + private let preferences: AuthPreferences - init(api: OrderAPI) { + init(api: OrderAPI, preferences: AuthPreferences) { self.api = api + self.preferences = preferences } func getOrderList() async throws -> OrderList { @@ -20,8 +22,22 @@ class OrderRepositoryImpl: OrderRepository { return OrderList(items: orders) } - func createOrder() async throws -> OrderList { - let dtos = try await api.createOrder() + func createOrder(cartList: CartList) async throws -> OrderList { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let items: [OrderItems] = cartList.items + .flatMap { $0.groups } + .flatMap { $0.parts } + .map { part in + return OrderItems(code: part.code, quantity: Int64(part.quantity)) + } + let request = OrderRequestDto( + requester: "대리점", + branch: user.branch, + items: items + ) + let dtos = try await api.createOrder(orderRequestDto: request) let orders = dtos.map { $0.toModel() } return OrderList(items: orders) } diff --git a/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift index 158a046..def1ee3 100644 --- a/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift +++ b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift @@ -9,7 +9,7 @@ import Foundation protocol OrderRepository { func getOrderList() async throws -> OrderList - func createOrder() async throws -> OrderList + func createOrder(cartList: CartList) async throws -> OrderList func receiveOrder(orderId: Int) async throws func getOrderDetail(orderId: Int) async throws -> OrderList func cancelOrder(orderId: Int) async throws diff --git a/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift index e0f23fb..c348621 100644 --- a/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift +++ b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift @@ -14,7 +14,7 @@ class CreateOrderUseCase { self.repository = repository } - func execute() async throws -> OrderList { - return try await repository.createOrder() + func execute(cartList: CartList) async throws -> OrderList { + return try await repository.createOrder(cartList: cartList) } } From 7d440012c7dc1c443be4594c336cc814f92a3004 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sun, 2 Nov 2025 23:25:16 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[REFAC]=20=EC=A3=BC=EB=AC=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95,=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=A0=84=EC=97=AD=20=EC=8A=A4=EB=82=B5?= =?UTF-8?q?=EB=B0=94=20=EC=A0=81=EC=9A=A9,=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/RootView.swift | 6 + .../Core/DI/AppDependencies.swift | 19 ++- .../Core/Network/APIResponse.swift | 8 +- .../Core/Network/NetworkError.swift | 26 +++- .../Core/Network/NetworkManager.swift | 143 ++++++++++++++++-- .../Core/Resources/StringResources.swift | 14 +- .../Core/UI/Components/ToastContainer.swift | 34 +++++ .../Core/Utilities/GlobalMessageHandler.swift | 32 ++++ .../Cart/Data/Remote/API/CartAPI.swift | 12 +- .../Features/Cart/UI/CartListUiEvent.swift | 2 - .../Features/Cart/UI/CartListUiState.swift | 26 +--- .../Features/Cart/UI/CartListView.swift | 50 +----- .../Features/Cart/UI/CartListViewModel.swift | 76 ++++------ .../Order/Data/Remote/API/OrderAPI.swift | 63 ++++---- .../Order/Data/Remote/DTO/OrderListDto.swift | 20 +++ .../Data/Remote/DTO/OrderRequestDto.swift | 9 +- .../Data/Repository/OrderRepositoryImpl.swift | 56 ++++--- .../Domain/Repository/OrderRepository.swift | 6 +- .../Domain/UseCase/CreateOrderUseCase.swift | 2 +- .../UseCase/GetOrderDetailUseCase.swift | 2 +- .../Domain/UseCase/GetOrderUseCase.swift | 4 +- .../Order/UI/OrderDetailContent.swift | 39 +++-- .../Order/UI/OrderDetailUiEvent.swift | 1 - .../Order/UI/OrderDetailUiState.swift | 22 +-- .../Features/Order/UI/OrderDetailView.swift | 38 +---- .../Order/UI/OrderDetailViewModel.swift | 49 +++--- .../Features/Order/UI/OrderListUiEvent.swift | 1 + .../Features/Order/UI/OrderListUiState.swift | 20 ++- .../Features/Order/UI/OrderListView.swift | 36 +++-- .../Order/UI/OrderListViewModel.swift | 35 +++-- .../Order/UI/OrderResultBottomSheet.swift | 27 +--- .../Data/Remote/API/OutboundAPI.swift | 10 +- .../Part/UI/PartDetailBottomSheetView.swift | 19 +-- .../Features/Part/UI/PartDetailUiEvent.swift | 1 - .../Features/Part/UI/PartDetailUiState.swift | 5 - .../Part/UI/PartDetailViewModel.swift | 43 +++--- .../Features/Part/UI/PartListView.swift | 5 +- .../Features/Part/UI/SearchResultView.swift | 5 +- 38 files changed, 571 insertions(+), 395 deletions(-) create mode 100644 SampoomManagement/Core/UI/Components/ToastContainer.swift create mode 100644 SampoomManagement/Core/Utilities/GlobalMessageHandler.swift create mode 100644 SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift index 48ed5c0..e05c8c4 100644 --- a/SampoomManagement/App/RootView.swift +++ b/SampoomManagement/App/RootView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Toast struct RootView: View { let dependencies: AppDependencies @@ -13,6 +14,7 @@ struct RootView: View { @StateObject private var loginViewModel: LoginViewModel @StateObject private var signUpViewModel: SignUpViewModel @ObservedObject private var authViewModel: AuthViewModel + @ObservedObject private var globalMessageHandler: GlobalMessageHandler @State private var showSignUp: Bool = false init(dependencies: AppDependencies) { @@ -20,6 +22,7 @@ struct RootView: View { _loginViewModel = StateObject(wrappedValue: dependencies.makeLoginViewModel()) _signUpViewModel = StateObject(wrappedValue: dependencies.makeSignUpViewModel()) self.authViewModel = dependencies.authViewModel + self.globalMessageHandler = dependencies.globalMessageHandler } var body: some View { @@ -70,6 +73,9 @@ struct RootView: View { } } } + + // Toast 컨테이너 (앱 최상단에 배치) + ToastContainer(globalMessageHandler: globalMessageHandler) } .onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in if shouldNavigate { diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index b12d417..a0868f4 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -12,6 +12,7 @@ import SwiftUI class AppDependencies { // MARK: - Core let networkManager: NetworkManager + let globalMessageHandler: GlobalMessageHandler // MARK: - Auth let authPreferences: AuthPreferences @@ -65,6 +66,9 @@ class AppDependencies { let cancelOrderUseCase: CancelOrderUseCase init() { + // Global Message Handler + globalMessageHandler = GlobalMessageHandler.shared + // Auth Preferences authPreferences = AuthPreferences() @@ -158,7 +162,11 @@ class AppDependencies { } func makePartDetailViewModel() -> PartDetailViewModel { - return PartDetailViewModel(addOutboundUseCase: addOutboundUseCase, addCartUseCase: addCartUseCase) + return PartDetailViewModel( + addOutboundUseCase: addOutboundUseCase, + addCartUseCase: addCartUseCase, + globalMessageHandler: globalMessageHandler + ) } func makeSearchViewModel() -> SearchViewModel { @@ -182,12 +190,16 @@ class AppDependencies { updateCartQuantityUseCase: updateCartQuantityUseCase, deleteCartUseCase: deleteCartUseCase, deleteAllCartUseCase: deleteAllCartUseCase, - createOrderUseCase: createOrderUseCase + createOrderUseCase: createOrderUseCase, + globalMessageHandler: globalMessageHandler ) } func makeOrderListViewModel() -> OrderListViewModel { - return OrderListViewModel(getOrderUseCase: getOrderUseCase) + return OrderListViewModel( + getOrderUseCase: getOrderUseCase, + globalMessageHandler: globalMessageHandler + ) } func makeOrderDetailViewModel(orderId: Int) -> OrderDetailViewModel { @@ -195,6 +207,7 @@ class AppDependencies { getOrderDetailUseCase: getOrderDetailUseCase, cancelOrderUseCase: cancelOrderUseCase, receiveOrderUseCase: receiveOrderUseCase, + globalMessageHandler: globalMessageHandler, orderId: orderId ) } diff --git a/SampoomManagement/Core/Network/APIResponse.swift b/SampoomManagement/Core/Network/APIResponse.swift index df4cfb8..9590b17 100644 --- a/SampoomManagement/Core/Network/APIResponse.swift +++ b/SampoomManagement/Core/Network/APIResponse.swift @@ -5,7 +5,7 @@ // Created by 채상윤 on 9/29/25. // -import Foundation +@preconcurrency import Foundation struct APIResponse: Codable { let status: Int @@ -16,3 +16,9 @@ struct APIResponse: Codable { struct EmptyResponse: Codable { } + +/// API 에러 응답 (안드로이드와 동일한 구조) +struct ApiErrorResponse: Codable { + let code: Int? + let message: String? +} diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index fb4f22b..6f013e0 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -12,7 +12,7 @@ enum NetworkError: Error, LocalizedError { case decodingError(Error) case invalidURL case noData - case serverError(Int) + case serverError(Int, message: String?) case invalidParameters case unauthorized @@ -21,12 +21,19 @@ enum NetworkError: Error, LocalizedError { case .networkError(let error): return "네트워크 오류: \(error.localizedDescription)" case .decodingError(let error): + // 디코딩 에러의 실제 메시지 추출 시도 + if let decodingError = error as? DecodingError { + return decodingErrorMessage(decodingError) + } return "데이터 파싱 오류: \(error.localizedDescription)" case .invalidURL: return "잘못된 URL" case .noData: return "데이터가 없습니다" - case .serverError(let code): + case .serverError(let code, let message): + if let message = message, !message.isEmpty { + return message + } return "서버 오류: \(code)" case .invalidParameters: return "잘못된 매개변수입니다" @@ -34,6 +41,21 @@ enum NetworkError: Error, LocalizedError { return "인증이 필요합니다" } } + + private func decodingErrorMessage(_ error: DecodingError) -> String { + switch error { + case .dataCorrupted(let context): + return "데이터 형식 오류: \(context.debugDescription)" + case .keyNotFound(let key, _): + return "필수 데이터 누락: \(key.stringValue)" + case .typeMismatch(let type, _): + return "데이터 형식 불일치: \(type)" + case .valueNotFound(let type, _): + return "필수 값 누락: \(type)" + @unknown default: + return "데이터 파싱 오류" + } + } } enum AuthError: Error, LocalizedError { diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index be0125c..bfe7790 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -5,7 +5,7 @@ // Created by 채상윤 on 9/29/25. // -import Foundation +@preconcurrency import Foundation import Alamofire class NetworkManager { @@ -18,6 +18,27 @@ class NetworkManager { self.session = Session(configuration: configuration, interceptor: authRequestInterceptor) } + // 디코딩을 main actor 컨텍스트에서 수행 + // Swift 6 strict concurrency: 타입이 actor와 격리되지 않았음을 보장하기 위해 + // @MainActor 함수에서 직접 디코딩 + @MainActor private func decodeApiErrorResponse(from data: Data) -> ApiErrorResponse? { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try? decoder.decode(ApiErrorResponse.self, from: data) + } + + @MainActor private func decodeApiResponse(_ type: T.Type, from data: Data) throws -> APIResponse { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try decoder.decode(APIResponse.self, from: data) + } + + @MainActor private func decodeEmptyApiResponse(from data: Data) -> APIResponse? { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try? decoder.decode(APIResponse.self, from: data) + } + func request( endpoint: String, method: HTTPMethod = .get, @@ -36,17 +57,118 @@ class NetworkManager { ) dataRequest.responseData { response in + // HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도 + if let httpResponse = response.response, + httpResponse.statusCode >= 400, + let data = response.data { + + Task { @MainActor in + // 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일) + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let errorCode = errorResponse.code ?? httpResponse.statusCode + continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message)) + return + } + + // 2. APIResponse 형식으로 파싱 시도 (기존 방식) + if let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + // 3. 파싱 실패 시 기본 에러 + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } + return + } + switch response.result { case .success(let data): - do { - print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") - let decoder = JSONDecoder() - let apiResponse = try decoder.decode(APIResponse.self, from: data) - print("NetworkManager - Decoded response: \(apiResponse)") - continuation.resume(returning: apiResponse) - } catch { - print("NetworkManager - Decoding error: \(error)") - continuation.resume(throwing: NetworkError.decodingError(error)) + Task { @MainActor in + do { + print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") + let apiResponse = try self.decodeApiResponse(T.self, from: data) + print("NetworkManager - Decoded response: \(apiResponse)") + continuation.resume(returning: apiResponse) + } catch { + print("NetworkManager - Decoding error: \(error)") + continuation.resume(throwing: NetworkError.decodingError(error)) + } + } + case .failure(let error): + print("NetworkManager - Network error: \(error)") + continuation.resume(throwing: NetworkError.networkError(error)) + } + } + } + }, onCancel: { + }) + } + + func request( + endpoint: String, + method: HTTPMethod = .get, + body: E? = nil, + responseType: T.Type + ) async throws -> APIResponse { + let url = baseURL + endpoint + + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { continuation in + let dataRequest: DataRequest + if let body = body { + dataRequest = session.request( + url, + method: method, + parameters: body, + encoder: JSONParameterEncoder.default + ) + } else { + dataRequest = session.request( + url, + method: method, + encoding: method == .get ? URLEncoding.default : JSONEncoding.default + ) + } + + dataRequest.responseData { response in + // HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도 + if let httpResponse = response.response, + httpResponse.statusCode >= 400, + let data = response.data { + + Task { @MainActor in + // 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일) + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let errorCode = errorResponse.code ?? httpResponse.statusCode + continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message)) + return + } + + // 2. APIResponse 형식으로 파싱 시도 (기존 방식) + if let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + // 3. 파싱 실패 시 기본 에러 + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } + return + } + + switch response.result { + case .success(let data): + Task { @MainActor in + do { + print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") + let apiResponse = try self.decodeApiResponse(T.self, from: data) + print("NetworkManager - Decoded response: \(apiResponse)") + continuation.resume(returning: apiResponse) + } catch { + print("NetworkManager - Decoding error: \(error)") + continuation.resume(throwing: NetworkError.decodingError(error)) + } } case .failure(let error): print("NetworkManager - Network error: \(error)") @@ -58,3 +180,4 @@ class NetworkManager { }) } } + diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index a0acd05..90d1180 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -84,6 +84,7 @@ struct StringResources { static let done = "완료" static let error = "오류" static let retry = "다시 시도" + static let loadMore = "더 보기" } // MARK: - Search @@ -97,7 +98,7 @@ struct StringResources { // MARK: - Outbound struct Outbound { static let title = "출고" - static let emptyAll = "출고목록 비우기" + static let emptyAll = "비우기" static let processOrder = "부품 출고처리" static let orderSuccess = "출고 주문 성공" static let updateQuantityError = "수량 업데이트 에러" @@ -112,7 +113,7 @@ struct StringResources { // MARK: - Cart struct Cart { static let title = "장바구니" - static let emptyAll = "장바구니 비우기" + static let emptyAll = "비우기" static let processOrder = "부품 주문" static let orderSuccess = "주문 성공!" static let updateQuantityError = "수량 업데이트 에러" @@ -132,13 +133,13 @@ struct StringResources { static let quantity = "수량" static let addToOutbound = "출고 추가" static let addToCart = "장바구니 추가" - static let outboundSuccess = "출고 성공!" - static let cartSuccess = "장바구니 추가 성공!" + static let outboundSuccess = "출고 목록에 추가되었습니다" + static let cartSuccess = "장바구니 목록에 추가되었습니다" static let errorOccurred = "에러 발생" static let confirmOutboundTitle = "출고 확인" - static let confirmOutboundMessage = "선택하신 부품을 출고 목록에 추가하시겠습니까?" + static let confirmOutboundMessage = "출고 목록에 추가하시겠습니까?" static let confirmCartTitle = "장바구니 확인" - static let confirmCartMessage = "선택하신 부품을 장바구니에 추가하시겠습니까?" + static let confirmCartMessage = "장바구니 목록에 추가하시겠습니까?" } // MARK: - Part @@ -147,6 +148,7 @@ struct StringResources { static let selectCategory = "카테고리 선택" static let selectCategoryPrompt = "카테고리를 선택해주세요" static let selectGroup = "그룹 선택" + static let emptyPart = "부품 목록이 없습니다." } // MARK: - Order diff --git a/SampoomManagement/Core/UI/Components/ToastContainer.swift b/SampoomManagement/Core/UI/Components/ToastContainer.swift new file mode 100644 index 0000000..30b58cf --- /dev/null +++ b/SampoomManagement/Core/UI/Components/ToastContainer.swift @@ -0,0 +1,34 @@ +// +// ToastContainer.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import SwiftUI +import Toast +import UIKit + +/// 앱 최상단에 Toast를 표시하기 위한 컨테이너 뷰 +struct ToastContainer: View { + @ObservedObject var globalMessageHandler: GlobalMessageHandler + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onChange(of: globalMessageHandler.message) { _, message in + if let message = message, !message.isEmpty { + DispatchQueue.main.async { + showToastOnTopWindow(message) + } + } + } + } + + private func showToastOnTopWindow(_ message: String) { + // Toast 라이브러리는 기본적으로 앱의 최상단 window에 표시됩니다 + // RootView에서 호출되므로 자동으로 최상단에 표시됨 + Toast.text(message).show() + } +} + diff --git a/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift b/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift new file mode 100644 index 0000000..6586483 --- /dev/null +++ b/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift @@ -0,0 +1,32 @@ +// +// GlobalMessageHandler.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import Foundation +import SwiftUI +import Combine + +/// 전역 에러 메시지 핸들러 +class GlobalMessageHandler: ObservableObject { + @Published var message: String? + @Published var isError: Bool = false + + static let shared = GlobalMessageHandler() + + private init() {} + + func showMessage(_ message: String, isError: Bool = false) { + DispatchQueue.main.async { [weak self] in + self?.isError = isError + self?.message = message + // 메시지를 표시한 후 잠시 후 nil로 설정하여 같은 메시지도 다시 표시할 수 있도록 함 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.message = nil + } + } + } +} + diff --git a/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift index 37af8b3..294d63b 100644 --- a/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift +++ b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift @@ -22,7 +22,9 @@ class CartAPI { method: .get, responseType: [CartDto].self ) - print("CartAPI - getCartList response: \(response)") + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } return response.data ?? [] } @@ -36,7 +38,7 @@ class CartAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -48,7 +50,7 @@ class CartAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -62,7 +64,7 @@ class CartAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -74,7 +76,7 @@ class CartAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } } diff --git a/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift b/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift index 5572d0a..a35bbb7 100644 --- a/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift +++ b/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift @@ -14,8 +14,6 @@ enum CartListUiEvent { case updateQuantity(cartItemId: Int, quantity: Int) case deleteCart(cartItemId: Int) case deleteAllCart - case clearUpdateError - case clearDeleteError case dismissOrderResult } diff --git a/SampoomManagement/Features/Cart/UI/CartListUiState.swift b/SampoomManagement/Features/Cart/UI/CartListUiState.swift index e75afc3..c2425ce 100644 --- a/SampoomManagement/Features/Cart/UI/CartListUiState.swift +++ b/SampoomManagement/Features/Cart/UI/CartListUiState.swift @@ -10,71 +10,51 @@ import Foundation struct CartListUiState { let cartList: [Cart] let cartLoading: Bool - let cartError: String? let selectedCart: Cart? let isUpdating: Bool - let updateError: String? let isDeleting: Bool - let deleteError: String? let isOrderSuccess: Bool let isProcessing: Bool - let processError: String? - let processedOrder: [Order]? + let processedOrder: Order? init( cartList: [Cart] = [], cartLoading: Bool = false, - cartError: String? = nil, selectedCart: Cart? = nil, isUpdating: Bool = false, - updateError: String? = nil, isDeleting: Bool = false, - deleteError: String? = nil, isOrderSuccess: Bool = false, isProcessing: Bool = false, - processError: String? = nil, - processedOrder: [Order]? = nil + processedOrder: Order? = nil ) { self.cartList = cartList self.cartLoading = cartLoading - self.cartError = cartError self.selectedCart = selectedCart self.isUpdating = isUpdating - self.updateError = updateError self.isDeleting = isDeleting - self.deleteError = deleteError self.isOrderSuccess = isOrderSuccess self.isProcessing = isProcessing - self.processError = processError self.processedOrder = processedOrder } func copy( cartList: [Cart]? = nil, cartLoading: Bool? = nil, - cartError: String?? = nil, selectedCart: Cart? = nil, isUpdating: Bool? = nil, - updateError: String?? = nil, isDeleting: Bool? = nil, - deleteError: String?? = nil, isOrderSuccess: Bool? = nil, isProcessing: Bool? = nil, - processError: String?? = nil, - processedOrder: [Order]?? = nil + processedOrder: Order? = nil ) -> CartListUiState { return CartListUiState( cartList: cartList ?? self.cartList, cartLoading: cartLoading ?? self.cartLoading, - cartError: cartError ?? self.cartError, selectedCart: selectedCart ?? self.selectedCart, isUpdating: isUpdating ?? self.isUpdating, - updateError: updateError ?? self.updateError, isDeleting: isDeleting ?? self.isDeleting, - deleteError: deleteError ?? self.deleteError, isOrderSuccess: isOrderSuccess ?? self.isOrderSuccess, isProcessing: isProcessing ?? self.isProcessing, - processError: processError ?? self.processError, processedOrder: processedOrder ?? self.processedOrder ) } diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index 743c194..db2b11e 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -20,27 +20,15 @@ struct CartListView: View { get: { viewModel.uiState.isOrderSuccess }, set: { _ in } ) - let updateError = Binding( - get: { viewModel.uiState.updateError != nil }, - set: { _ in } - ) - let deleteError = Binding( - get: { viewModel.uiState.deleteError != nil }, - set: { _ in } - ) MainNavigationContent( shouldShowEmptyButton: shouldShowEmptyButton, showEmptyCartDialog: $showEmptyCartDialog, showConfirmDialog: $showConfirmDialog, isOrderSuccessBinding: isOrderSuccess, - hasUpdateError: updateError, - hasDeleteError: deleteError, onEmptyAll: { viewModel.onEvent(.deleteAllCart) }, onProcessOrder: { viewModel.onEvent(.processOrder) }, onAppear: { viewModel.onEvent(.loadCartList) }, - onClearUpdateError: { viewModel.onEvent(.clearUpdateError) }, - onClearDeleteError: { viewModel.onEvent(.clearDeleteError) }, cartContent: { AnyView(cartContent) }, orderResultSheet: { AnyView( @@ -51,7 +39,7 @@ struct CartListView: View { onDismiss: { viewModel.onEvent(.dismissOrderResult) }, - viewModel: dependencies.makeOrderDetailViewModel(orderId: processedOrder.first?.orderId ?? 0) + viewModel: dependencies.makeOrderDetailViewModel(orderId: processedOrder.orderId) ) } else { EmptyView() @@ -67,8 +55,6 @@ struct CartListView: View { private var cartContent: some View { if viewModel.uiState.cartLoading { loadingView - } else if let error = viewModel.uiState.cartError { - errorView(error: error) } else if viewModel.uiState.cartList.isEmpty { emptyView } else { @@ -86,20 +72,6 @@ struct CartListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func errorView(error: String) -> some View { - HStack { - Spacer() - ErrorView( - error: error, - onRetry: { - viewModel.onEvent(.retryCartList) - } - ) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - private var emptyView: some View { HStack { Spacer() @@ -144,7 +116,6 @@ struct CartListView: View { private var shouldShowEmptyButton: Bool { !viewModel.uiState.cartLoading && - viewModel.uiState.cartError == nil && !viewModel.uiState.cartList.isEmpty } @@ -155,13 +126,9 @@ private struct MainNavigationContent: View { @Binding var showEmptyCartDialog: Bool @Binding var showConfirmDialog: Bool let isOrderSuccessBinding: Binding - let hasUpdateError: Binding - let hasDeleteError: Binding let onEmptyAll: () -> Void let onProcessOrder: () -> Void let onAppear: () -> Void - let onClearUpdateError: () -> Void - let onClearDeleteError: () -> Void let cartContent: () -> AnyView let orderResultSheet: () -> AnyView @ObservedObject var viewModel: CartListViewModel @@ -203,21 +170,6 @@ private struct MainNavigationContent: View { .onAppear { onAppear() } - .onChange(of: isOrderSuccessBinding.wrappedValue) { _, newValue in - if newValue { - // handled by parent - } - } - .onChange(of: hasUpdateError.wrappedValue) { _, newValue in - if newValue { - onClearUpdateError() - } - } - .onChange(of: hasDeleteError.wrappedValue) { _, newValue in - if newValue { - onClearDeleteError() - } - } .sheet(isPresented: Binding( get: { viewModel.uiState.processedOrder != nil && viewModel.uiState.isOrderSuccess }, set: { _ in } diff --git a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift index 094e95a..8d51bbf 100644 --- a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -18,19 +18,22 @@ class CartListViewModel: ObservableObject { private let deleteCartUseCase: DeleteCartUseCase private let deleteAllCartUseCase: DeleteAllCartUseCase private let createOrderUseCase: CreateOrderUseCase + private let globalMessageHandler: GlobalMessageHandler init( getCartUseCase: GetCartUseCase, updateCartQuantityUseCase: UpdateCartQuantityUseCase, deleteCartUseCase: DeleteCartUseCase, deleteAllCartUseCase: DeleteAllCartUseCase, - createOrderUseCase: CreateOrderUseCase + createOrderUseCase: CreateOrderUseCase, + globalMessageHandler: GlobalMessageHandler ) { self.getCartUseCase = getCartUseCase self.updateCartQuantityUseCase = updateCartQuantityUseCase self.deleteCartUseCase = deleteCartUseCase self.deleteAllCartUseCase = deleteAllCartUseCase self.createOrderUseCase = createOrderUseCase + self.globalMessageHandler = globalMessageHandler } func onEvent(_ event: CartListUiEvent) { @@ -47,40 +50,33 @@ class CartListViewModel: ObservableObject { deleteCart(cartItemId: cartItemId) case .deleteAllCart: deleteAllCart() - case .clearUpdateError: - uiState = uiState.copy(updateError: .some(nil)) - case .clearDeleteError: - uiState = uiState.copy(deleteError: .some(nil)) case .dismissOrderResult: - uiState = uiState.copy(isOrderSuccess: false, processedOrder: .some(nil)) + uiState = uiState.copy(isOrderSuccess: false, processedOrder: nil) } } private func loadCartList() { Task { await MainActor.run { - uiState = uiState.copy(cartLoading: true, cartError: nil) + uiState = uiState.copy(cartLoading: true) } do { - let cartList = try await getCartUseCase.execute() + let cartList = try await getCartUseCase.execute() await MainActor.run { uiState = uiState.copy( cartList: cartList.items, - cartLoading: false, - cartError: nil + cartLoading: false ) } } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - cartLoading: false, - cartError: error.localizedDescription - ) + uiState = uiState.copy(cartLoading: false) } } - print("CartListViewModel - loadCartList: \(uiState)") } } @@ -88,29 +84,30 @@ class CartListViewModel: ObservableObject { guard !uiState.isProcessing else { return } Task { await MainActor.run { - uiState = uiState.copy(isProcessing: true, processError: nil) + uiState = uiState.copy(isProcessing: true) } do { - let orderList = try await createOrderUseCase.execute() + let cartList = CartList(items: uiState.cartList) + let order = try await createOrderUseCase.execute(cartList: cartList) await MainActor.run { uiState = uiState.copy( isOrderSuccess: true, isProcessing: false, - processedOrder: orderList.items + processedOrder: order ) } + globalMessageHandler.showMessage(StringResources.Cart.orderSuccess, isError: false) await MainActor.run { + deleteAllCart() loadCartList() // 주문 후 장바구니 새로고침 } } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isProcessing: false, - processError: error.localizedDescription - ) + uiState = uiState.copy(isProcessing: false) } } - print("CartListViewModel - processOrder: \(uiState)") } } @@ -121,7 +118,7 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { await MainActor.run { - uiState = uiState.copy(isUpdating: true, updateError: nil) + uiState = uiState.copy(isUpdating: true) } do { @@ -129,17 +126,14 @@ class CartListViewModel: ObservableObject { await MainActor.run { uiState = uiState.copy(isUpdating: false) } - print("CartListViewModel - updateQuantity success: \(uiState)") } catch { // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + uiState = uiState.copy(isUpdating: false) } loadCartList() // 에러 표시 후 백그라운드에서 롤백 - print("CartListViewModel - updateQuantity error: \(error)") } } } @@ -180,7 +174,7 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { await MainActor.run { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + uiState = uiState.copy(isDeleting: true) } do { @@ -188,17 +182,14 @@ class CartListViewModel: ObservableObject { await MainActor.run { uiState = uiState.copy(isDeleting: false) } - print("CartListViewModel - deleteCart success: \(uiState)") } catch { // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + uiState = uiState.copy(isDeleting: false) } loadCartList() // 에러 표시 후 백그라운드에서 롤백 - print("CartListViewModel - deleteCart error: \(error)") } } } @@ -229,7 +220,7 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { await MainActor.run { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + uiState = uiState.copy(isDeleting: true) } do { @@ -237,17 +228,14 @@ class CartListViewModel: ObservableObject { await MainActor.run { uiState = uiState.copy(isDeleting: false) } - print("CartListViewModel - deleteAllCart success: \(uiState)") } catch { // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + uiState = uiState.copy(isDeleting: false) } loadCartList() // 에러 표시 후 백그라운드에서 롤백 - print("CartListViewModel - deleteAllCart error: \(error)") } } } diff --git a/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift index 12f087e..dbd0989 100644 --- a/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift +++ b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift @@ -16,36 +16,33 @@ class OrderAPI { } /// 주문 목록 조회 - func getOrderList() async throws -> [OrderDto] { - let response: APIResponse<[OrderDto]> = try await networkManager.request( - endpoint: "/agency/1/orders", + func getOrderList(agencyName: String, page: Int = 0, size: Int = 20) async throws -> OrderListDto { + let encodedAgencyName = agencyName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? agencyName + let endpoint = "order/requested?from=\(encodedAgencyName)&page=\(page)&size=\(size)" + + let response: APIResponse = try await networkManager.request( + endpoint: endpoint, method: .get, - responseType: [OrderDto].self + responseType: OrderListDto.self ) - guard response.success else { throw NetworkError.serverError(response.status) } - return response.data ?? [] + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + + return response.data ?? OrderListDto(content: [], totalElements: 0, totalPages: 0, number: 0, last: true, size: size, first: true) } /// 주문 생성 - func createOrder(orderRequestDto: OrderRequestDto) async throws -> [OrderDto] { - let itemsParams: [[String: Any]] = orderRequestDto.items.map { item in - return [ - "code": item.code, - "quantity": item.quantity - ] - } - let parameters: [String: Any] = [ - "branch": orderRequestDto.branch, - "items": itemsParams - ] - let response: APIResponse<[OrderDto]> = try await networkManager.request( + func createOrder(orderRequestDto: OrderRequestDto) async throws -> OrderDto { + let response: APIResponse = try await networkManager.request( endpoint: "order/", method: .post, - parameters: parameters, - responseType: [OrderDto].self + body: orderRequestDto, + responseType: OrderDto.self ) - guard response.success else { throw NetworkError.serverError(response.status) } - return response.data ?? [] + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { + throw NetworkError.noData + } + return data } /// 주문 입고 처리 @@ -55,26 +52,30 @@ class OrderAPI { method: .patch, responseType: EmptyResponse.self ) - if !response.success { throw NetworkError.serverError(response.status) } + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } } /// 주문 상세 조회 - func getOrderDetail(orderId: Int) async throws -> [OrderDto] { - let response: APIResponse<[OrderDto]> = try await networkManager.request( - endpoint: "/agency/1/orders/\(orderId)", + func getOrderDetail(orderId: Int) async throws -> OrderDto { + let response: APIResponse = try await networkManager.request( + endpoint: "order/\(orderId)", method: .get, - responseType: [OrderDto].self + responseType: OrderDto.self ) - return response.data ?? [] + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { + throw NetworkError.noData + } + return data } /// 주문 취소 func cancelOrder(orderId: Int) async throws { let response: APIResponse = try await networkManager.request( - endpoint: "/agency/1/orders/\(orderId)", - method: .delete, + endpoint: "order/cancel/\(orderId)", + method: .patch, responseType: EmptyResponse.self ) - if !response.success { throw NetworkError.serverError(response.status) } + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } } } diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift new file mode 100644 index 0000000..e5d7e66 --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift @@ -0,0 +1,20 @@ +// +// OrderListDto.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import Foundation + +/// 주문 목록 페이징 DTO +struct OrderListDto: Codable { + let content: [OrderDto] + let totalElements: Int + let totalPages: Int + let number: Int // 현재 페이지 번호 + let last: Bool // 마지막 페이지 여부 + let size: Int + let first: Bool +} + diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift index d05743a..27ff1e9 100644 --- a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift @@ -9,13 +9,8 @@ import Foundation /// 주문 생성 요청 DTO struct OrderRequestDto: Codable { - let branch: String - let items: [OrderItems] -} - -struct OrderItems: Codable { - let code: String - let quantity: Int64 + let agencyName: String + let items: [OrderCategoryDto] } diff --git a/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift index 8e0392e..f123031 100644 --- a/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift +++ b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift @@ -16,40 +16,56 @@ class OrderRepositoryImpl: OrderRepository { self.preferences = preferences } - func getOrderList() async throws -> OrderList { - let dtos = try await api.getOrderList() - let orders = dtos.map { $0.toModel() } - return OrderList(items: orders) + func getOrderList(page: Int, size: Int) async throws -> (items: [Order], hasMore: Bool) { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let dto = try await api.getOrderList(agencyName: user.branch, page: page, size: size) + let orders = dto.content.map { $0.toModel() } + // last가 false면 더 많은 페이지가 있음 + let hasMore = !dto.last + return (items: orders, hasMore: hasMore) } - func createOrder(cartList: CartList) async throws -> OrderList { + func createOrder(cartList: CartList) async throws -> Order { guard let user = try preferences.getStoredUser() else { throw NetworkError.unauthorized } - let items: [OrderItems] = cartList.items - .flatMap { $0.groups } - .flatMap { $0.parts } - .map { part in - return OrderItems(code: part.code, quantity: Int64(part.quantity)) - } + let items = cartList.items.map { cart in + OrderCategoryDto( + categoryId: cart.categoryId, + categoryName: cart.categoryName, + groups: cart.groups.map { group in + OrderGroupDto( + groupId: group.groupId, + groupName: group.groupName, + parts: group.parts.map { part in + OrderPartDto( + partId: part.partId, + code: part.code, + name: part.name, + quantity: part.quantity + ) + } + ) + } + ) + } let request = OrderRequestDto( - requester: "대리점", - branch: user.branch, + agencyName: user.branch, items: items ) - let dtos = try await api.createOrder(orderRequestDto: request) - let orders = dtos.map { $0.toModel() } - return OrderList(items: orders) + let dto = try await api.createOrder(orderRequestDto: request) + return dto.toModel() } func receiveOrder(orderId: Int) async throws { try await api.receiveOrder(orderId: orderId) } - func getOrderDetail(orderId: Int) async throws -> OrderList { - let dtos = try await api.getOrderDetail(orderId: orderId) - let orders = dtos.map { $0.toModel() } - return OrderList(items: orders) + func getOrderDetail(orderId: Int) async throws -> Order { + let dto = try await api.getOrderDetail(orderId: orderId) + return dto.toModel() } func cancelOrder(orderId: Int) async throws { diff --git a/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift index def1ee3..5f06e0f 100644 --- a/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift +++ b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift @@ -8,9 +8,9 @@ import Foundation protocol OrderRepository { - func getOrderList() async throws -> OrderList - func createOrder(cartList: CartList) async throws -> OrderList + func getOrderList(page: Int, size: Int) async throws -> (items: [Order], hasMore: Bool) + func createOrder(cartList: CartList) async throws -> Order func receiveOrder(orderId: Int) async throws - func getOrderDetail(orderId: Int) async throws -> OrderList + func getOrderDetail(orderId: Int) async throws -> Order func cancelOrder(orderId: Int) async throws } diff --git a/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift index c348621..a5157a9 100644 --- a/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift +++ b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift @@ -14,7 +14,7 @@ class CreateOrderUseCase { self.repository = repository } - func execute(cartList: CartList) async throws -> OrderList { + func execute(cartList: CartList) async throws -> Order { return try await repository.createOrder(cartList: cartList) } } diff --git a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift index 6b31eb6..e7aed46 100644 --- a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift +++ b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift @@ -14,7 +14,7 @@ class GetOrderDetailUseCase { self.repository = repository } - func execute(orderId: Int) async throws -> OrderList { + func execute(orderId: Int) async throws -> Order { return try await repository.getOrderDetail(orderId: orderId) } } diff --git a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift index aa57e3a..3356578 100644 --- a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift +++ b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift @@ -14,7 +14,7 @@ class GetOrderUseCase { self.repository = repository } - func execute() async throws -> OrderList { - return try await repository.getOrderList() + func execute(page: Int = 0, size: Int = 20) async throws -> (items: [Order], hasMore: Bool) { + return try await repository.getOrderList(page: page, size: size) } } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailContent.swift b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift index b342e11..78c1827 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailContent.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift @@ -9,32 +9,31 @@ import SwiftUI import Combine struct OrderDetailContent: View { - let order: [Order] + let order: Order var body: some View { ScrollView { LazyVStack(spacing: 16) { - ForEach(order, id: \.orderId) { orderItem in - OrderInfoCard( - order: orderItem - ) - - Text(StringResources.Order.detailOrderItemsTitle) - .font(.gmarketTitle2) - .fontWeight(.bold) - .foregroundColor(Color("Text")) - .frame(maxWidth: .infinity, alignment: .leading) - - ForEach(orderItem.items, id: \.categoryId) { category in - ForEach(category.groups, id: \.groupId) { group in - OrderSection( - categoryName: category.categoryName, - groupName: group.groupName, - parts: group.parts - ) - } + OrderInfoCard( + order: order + ) + + Text(StringResources.Order.detailOrderItemsTitle) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(order.items, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in + OrderSection( + categoryName: category.categoryName, + groupName: group.groupName, + parts: group.parts + ) } } + Spacer() .frame(height: 100) } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift b/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift index 3be2436..341f913 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift @@ -12,5 +12,4 @@ enum OrderDetailUiEvent { case retryOrder case receiveOrder case cancelOrder - case clearError } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift b/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift index 7bd391c..edbf701 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift @@ -8,49 +8,39 @@ import Foundation struct OrderDetailUiState { - let orderDetail: [Order] + let orderDetail: Order? let orderDetailLoading: Bool - let orderDetailError: String? let isProcessing: Bool let isProcessingCancelSuccess: Bool let isProcessingReceiveSuccess: Bool - let isProcessingError: String? init( - orderDetail: [Order] = [], + orderDetail: Order? = nil, orderDetailLoading: Bool = false, - orderDetailError: String? = nil, isProcessing: Bool = false, isProcessingCancelSuccess: Bool = false, - isProcessingReceiveSuccess: Bool = false, - isProcessingError: String? = nil + isProcessingReceiveSuccess: Bool = false ) { self.orderDetail = orderDetail self.orderDetailLoading = orderDetailLoading - self.orderDetailError = orderDetailError self.isProcessing = isProcessing self.isProcessingCancelSuccess = isProcessingCancelSuccess self.isProcessingReceiveSuccess = isProcessingReceiveSuccess - self.isProcessingError = isProcessingError } func copy( - orderDetail: [Order]? = nil, + orderDetail: Order? = nil, orderDetailLoading: Bool? = nil, - orderDetailError: String?? = nil, isProcessing: Bool? = nil, isProcessingCancelSuccess: Bool? = nil, - isProcessingReceiveSuccess: Bool? = nil, - isProcessingError: String?? = nil + isProcessingReceiveSuccess: Bool? = nil ) -> OrderDetailUiState { return OrderDetailUiState( orderDetail: orderDetail ?? self.orderDetail, orderDetailLoading: orderDetailLoading ?? self.orderDetailLoading, - orderDetailError: orderDetailError ?? self.orderDetailError, isProcessing: isProcessing ?? self.isProcessing, isProcessingCancelSuccess: isProcessingCancelSuccess ?? self.isProcessingCancelSuccess, - isProcessingReceiveSuccess: isProcessingReceiveSuccess ?? self.isProcessingReceiveSuccess, - isProcessingError: isProcessingError ?? self.isProcessingError + isProcessingReceiveSuccess: isProcessingReceiveSuccess ?? self.isProcessingReceiveSuccess ) } } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailView.swift b/SampoomManagement/Features/Order/UI/OrderDetailView.swift index f30a9c4..a62cac6 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailView.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailView.swift @@ -26,29 +26,17 @@ struct OrderDetailView: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = viewModel.uiState.orderDetailError { - HStack { - Spacer() - ErrorView( - error: error, - onRetry: { - viewModel.onEvent(.retryOrder) - } - ) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.uiState.orderDetail.isEmpty { + } else if let order = viewModel.uiState.orderDetail { + OrderDetailContent( + order: order + ) + } else { HStack { Spacer() EmptyView(title: StringResources.Order.emptyList) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - OrderDetailContent( - order: viewModel.uiState.orderDetail - ) } } @@ -78,7 +66,7 @@ struct OrderDetailView: View { } } - private var hasOrderDetail: Bool { !viewModel.uiState.orderDetail.isEmpty } + private var hasOrderDetail: Bool { viewModel.uiState.orderDetail != nil } var body: some View { ZStack(alignment: .bottom) { @@ -108,29 +96,19 @@ struct OrderDetailView: View { } .onChange(of: viewModel.uiState.isProcessingCancelSuccess) { _, newValue in if newValue { - // Toast 대신 Alert 사용하거나 다른 방법으로 처리 viewModel.clearSuccess() - viewModel.onEvent(.loadOrder) } } .onChange(of: viewModel.uiState.isProcessingReceiveSuccess) { _, newValue in if newValue { - // Toast 대신 Alert 사용하거나 다른 방법으로 처리 viewModel.clearSuccess() - viewModel.onEvent(.loadOrder) - } - } - .onChange(of: viewModel.uiState.isProcessingError) { _, newValue in - if newValue != nil { - // Toast 대신 Alert 사용하거나 다른 방법으로 처리 - viewModel.onEvent(.clearError) } } } private var cannotPerformAction: Bool { - guard let order = viewModel.uiState.orderDetail.first else { return true } - return order.status == .completed || order.status == .canceled + guard let order = viewModel.uiState.orderDetail else { return true } + return order.status == .completed || order.status == .canceled || viewModel.uiState.isProcessing } } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift b/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift index 5334ef0..2802e1c 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift @@ -16,6 +16,7 @@ class OrderDetailViewModel: ObservableObject { private let getOrderDetailUseCase: GetOrderDetailUseCase private let cancelOrderUseCase: CancelOrderUseCase private let receiveOrderUseCase: ReceiveOrderUseCase + private let globalMessageHandler: GlobalMessageHandler private var orderId: Int = 0 @@ -23,11 +24,13 @@ class OrderDetailViewModel: ObservableObject { getOrderDetailUseCase: GetOrderDetailUseCase, cancelOrderUseCase: CancelOrderUseCase, receiveOrderUseCase: ReceiveOrderUseCase, + globalMessageHandler: GlobalMessageHandler, orderId: Int = 0 ) { self.getOrderDetailUseCase = getOrderDetailUseCase self.cancelOrderUseCase = cancelOrderUseCase self.receiveOrderUseCase = receiveOrderUseCase + self.globalMessageHandler = globalMessageHandler self.orderId = orderId } @@ -44,8 +47,6 @@ class OrderDetailViewModel: ObservableObject { receiveOrder() case .cancelOrder: cancelOrder() - case .clearError: - uiState = uiState.copy(isProcessingError: nil) } } @@ -58,20 +59,18 @@ class OrderDetailViewModel: ObservableObject { private func loadOrderDetail() { Task { - uiState = uiState.copy(orderDetailLoading: true, orderDetailError: nil) + uiState = uiState.copy(orderDetailLoading: true) do { - let orderList = try await getOrderDetailUseCase.execute(orderId: orderId) + let order = try await getOrderDetailUseCase.execute(orderId: orderId) uiState = uiState.copy( - orderDetail: orderList.items, - orderDetailLoading: false, - orderDetailError: nil + orderDetail: order, + orderDetailLoading: false ) } catch { - uiState = uiState.copy( - orderDetailLoading: false, - orderDetailError: error.localizedDescription - ) + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(orderDetailLoading: false) } print("OrderDetailViewModel - loadOrderDetail: \(uiState)") } @@ -80,20 +79,20 @@ class OrderDetailViewModel: ObservableObject { private func cancelOrder() { Task { print("OrderDetailViewModel - orderId : \(orderId)") - uiState = uiState.copy(isProcessing: true, isProcessingError: nil) + uiState = uiState.copy(isProcessing: true) do { try await cancelOrderUseCase.execute(orderId: orderId) + globalMessageHandler.showMessage(StringResources.Order.detailToastOrderCancel, isError: false) uiState = uiState.copy( isProcessing: false, - isProcessingCancelSuccess: true, - isProcessingError: nil + isProcessingCancelSuccess: true ) + loadOrderDetail() } catch { - uiState = uiState.copy( - isProcessing: false, - isProcessingError: error.localizedDescription - ) + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(isProcessing: false) } print("OrderDetailViewModel - cancelOrder: \(uiState)") } @@ -101,20 +100,20 @@ class OrderDetailViewModel: ObservableObject { private func receiveOrder() { Task { - uiState = uiState.copy(isProcessing: true, isProcessingError: nil) + uiState = uiState.copy(isProcessing: true) do { try await receiveOrderUseCase.execute(orderId: orderId) + globalMessageHandler.showMessage(StringResources.Order.detailToastOrderReceive, isError: false) uiState = uiState.copy( isProcessing: false, - isProcessingReceiveSuccess: true, - isProcessingError: nil + isProcessingReceiveSuccess: true ) + loadOrderDetail() } catch { - uiState = uiState.copy( - isProcessing: false, - isProcessingError: error.localizedDescription - ) + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(isProcessing: false) } print("OrderDetailViewModel - receiveOrder: \(uiState)") } diff --git a/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift b/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift index b4504fd..476ad4d 100644 --- a/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift +++ b/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift @@ -10,4 +10,5 @@ import Foundation enum OrderListUiEvent { case loadOrderList case retryOrderList + case loadMore } diff --git a/SampoomManagement/Features/Order/UI/OrderListUiState.swift b/SampoomManagement/Features/Order/UI/OrderListUiState.swift index 6e3eaef..525b46b 100644 --- a/SampoomManagement/Features/Order/UI/OrderListUiState.swift +++ b/SampoomManagement/Features/Order/UI/OrderListUiState.swift @@ -10,27 +10,37 @@ import Foundation struct OrderListUiState { let orderList: [Order] let orderLoading: Bool - let orderError: String? + let hasMore: Bool + let currentPage: Int + let isLoadingMore: Bool init( orderList: [Order] = [], orderLoading: Bool = false, - orderError: String? = nil + hasMore: Bool = true, + currentPage: Int = 0, + isLoadingMore: Bool = false ) { self.orderList = orderList self.orderLoading = orderLoading - self.orderError = orderError + self.hasMore = hasMore + self.currentPage = currentPage + self.isLoadingMore = isLoadingMore } func copy( orderList: [Order]? = nil, orderLoading: Bool? = nil, - orderError: String?? = nil + hasMore: Bool? = nil, + currentPage: Int? = nil, + isLoadingMore: Bool? = nil ) -> OrderListUiState { return OrderListUiState( orderList: orderList ?? self.orderList, orderLoading: orderLoading ?? self.orderLoading, - orderError: orderError ?? self.orderError + hasMore: hasMore ?? self.hasMore, + currentPage: currentPage ?? self.currentPage, + isLoadingMore: isLoadingMore ?? self.isLoadingMore ) } } diff --git a/SampoomManagement/Features/Order/UI/OrderListView.swift b/SampoomManagement/Features/Order/UI/OrderListView.swift index f4e611c..2f9a32a 100644 --- a/SampoomManagement/Features/Order/UI/OrderListView.swift +++ b/SampoomManagement/Features/Order/UI/OrderListView.swift @@ -15,7 +15,7 @@ struct OrderListView: View { var body: some View { VStack(spacing: 0) { // Content - if viewModel.uiState.orderLoading { + if viewModel.uiState.orderLoading && viewModel.uiState.orderList.isEmpty { HStack { Spacer() ProgressView() @@ -23,19 +23,7 @@ struct OrderListView: View { Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = viewModel.uiState.orderError { - HStack { - Spacer() - ErrorView( - error: error, - onRetry: { - viewModel.onEvent(.retryOrderList) - } - ) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.uiState.orderList.isEmpty { + } else if viewModel.uiState.orderList.isEmpty && !viewModel.uiState.orderLoading { HStack { Spacer() EmptyView(title: StringResources.Order.emptyList) @@ -53,6 +41,26 @@ struct OrderListView: View { } ) } + + if viewModel.uiState.hasMore { + Button(action: { + viewModel.onEvent(.loadMore) + }) { + HStack { + if viewModel.uiState.isLoadingMore { + ProgressView() + } else { + Text(StringResources.Common.loadMore) + .font(.gmarketBody) + .foregroundColor(.accentColor) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .disabled(viewModel.uiState.isLoadingMore || viewModel.uiState.orderLoading) + } + Spacer() .frame(height: 100) } diff --git a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift index a6bd6c9..7aa6348 100644 --- a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift +++ b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift @@ -15,36 +15,53 @@ class OrderListViewModel: ObservableObject { @Published var uiState = OrderListUiState() private let getOrderUseCase: GetOrderUseCase + private let globalMessageHandler: GlobalMessageHandler // MARK: - Initialization - init(getOrderUseCase: GetOrderUseCase) { + init(getOrderUseCase: GetOrderUseCase, globalMessageHandler: GlobalMessageHandler) { self.getOrderUseCase = getOrderUseCase + self.globalMessageHandler = globalMessageHandler } // MARK: - Actions func onEvent(_ event: OrderListUiEvent) { switch event { - case .loadOrderList, .retryOrderList: - loadOrderList() + case .loadOrderList: + loadOrderList(page: 0, append: false) + case .retryOrderList: + loadOrderList(page: 0, append: false) + case .loadMore: + guard uiState.hasMore, !uiState.orderLoading else { return } + loadOrderList(page: uiState.currentPage + 1, append: true) } } // MARK: - Private Methods - private func loadOrderList() { + private func loadOrderList(page: Int, append: Bool) { Task { - uiState = uiState.copy(orderLoading: true, orderError: nil) + if append { + uiState = uiState.copy(isLoadingMore: true) + } else { + uiState = uiState.copy(orderLoading: true) + } do { - let orderList = try await getOrderUseCase.execute() + let (items, hasMore) = try await getOrderUseCase.execute(page: page, size: 20) + let newOrders = append ? uiState.orderList + items : items + uiState = uiState.copy( - orderList: orderList.items, + orderList: newOrders, orderLoading: false, - orderError: nil + hasMore: hasMore, + currentPage: page, + isLoadingMore: false ) } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) uiState = uiState.copy( orderLoading: false, - orderError: error.localizedDescription + isLoadingMore: false ) } print("OrderListViewModel - loadOrderList: \(uiState)") diff --git a/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift b/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift index effbf6b..fd3edae 100644 --- a/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift +++ b/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift @@ -9,7 +9,7 @@ import SwiftUI import Combine struct OrderResultBottomSheet: View { - let order: [Order] + let order: Order let onDismiss: () -> Void @ObservedObject var viewModel: OrderDetailViewModel @@ -27,7 +27,7 @@ struct OrderResultBottomSheet: View { .frame(height: 16) OrderDetailContent( - order: viewModel.uiState.orderDetail.isEmpty ? order : viewModel.uiState.orderDetail + order: viewModel.uiState.orderDetail ?? order ) Spacer() @@ -50,40 +50,27 @@ struct OrderResultBottomSheet: View { .background(Color("Background")) .alert(StringResources.Order.detailDialogOrderCancel, isPresented: $showCancelDialog) { Button(StringResources.Common.ok) { - if let orderId = order.first?.orderId { - viewModel.setOrderId(orderId) - viewModel.onEvent(.cancelOrder) - } + viewModel.setOrderId(order.orderId) + viewModel.onEvent(.cancelOrder) } Button(StringResources.Common.cancel, role: .cancel) { } } .onAppear { - if let orderId = order.first?.orderId { - viewModel.setOrderId(orderId) - } + viewModel.setOrderId(order.orderId) } .onChange(of: viewModel.uiState.isProcessingCancelSuccess) { _, newValue in if newValue { - // Toast 대신 Alert 사용하거나 다른 방법으로 처리 viewModel.clearSuccess() viewModel.onEvent(.loadOrder) onDismiss() } } - .onChange(of: viewModel.uiState.isProcessingError) { _, newValue in - if newValue != nil { - // Toast 대신 Alert 사용하거나 다른 방법으로 처리 - viewModel.onEvent(.clearError) - } - } } private var isCancelEnabled: Bool { - guard order.first?.orderId != nil else { return false } if viewModel.uiState.isProcessing { return false } - let item = viewModel.uiState.orderDetail.first ?? order.first - guard let status = item?.status else { return false } - return status != .completed && status != .canceled + let currentOrder = viewModel.uiState.orderDetail ?? order + return currentOrder.status != .completed && currentOrder.status != .canceled } } diff --git a/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift index 421fcda..5336b87 100644 --- a/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift +++ b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift @@ -36,7 +36,7 @@ class OutboundAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -48,7 +48,7 @@ class OutboundAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -60,7 +60,7 @@ class OutboundAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -74,7 +74,7 @@ class OutboundAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } @@ -86,7 +86,7 @@ class OutboundAPI { responseType: EmptyResponse.self ) if !response.success { - throw NetworkError.serverError(response.status) + throw NetworkError.serverError(response.status, message: response.message) } } } diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index d947cb1..3a3ed8e 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -6,6 +6,7 @@ struct PartDetailBottomSheetView: View { @State private var showOutboundDialog = false @State private var showCartDialog = false @State private var quantityText: String = "1" + @Environment(\.dismiss) private var dismiss private func decreaseQuantity() { viewModel.onEvent(.decreaseQuantity) } private func increaseQuantity() { viewModel.onEvent(.increaseQuantity) } @@ -42,9 +43,6 @@ struct PartDetailBottomSheetView: View { .onChange(of: viewModel.uiState.isCartSuccess) { _, newValue in handleCartSuccess(newValue) } - .onChange(of: viewModel.uiState.updateError) { _, newValue in - handleUpdateError(newValue) - } .alert(StringResources.PartDetail.confirmOutboundTitle, isPresented: $showOutboundDialog) { Button(StringResources.Common.ok) { showOutboundDialog = false; addToOutbound() } Button(StringResources.Common.cancel, role: .cancel) { } @@ -105,24 +103,17 @@ struct PartDetailBottomSheetView: View { private func handleOutboundSuccess(_ newValue: Bool) { if newValue { - Toast.text(StringResources.PartDetail.outboundSuccess).show() showOutboundDialog = false - viewModel.clearSuccess() + // 성공 상태는 유지하고 바텀시트만 닫음 (메시지는 onDisappear에서 표시) + dismiss() } } private func handleCartSuccess(_ newValue: Bool) { if newValue { - Toast.text(StringResources.PartDetail.cartSuccess).show() showCartDialog = false - viewModel.clearSuccess() - } - } - - private func handleUpdateError(_ newValue: String?) { - if let error = newValue { - Toast.text("\(StringResources.PartDetail.errorOccurred): \(error)").show() - viewModel.onEvent(.clearError) + // 성공 상태는 유지하고 바텀시트만 닫음 (메시지는 onDisappear에서 표시) + dismiss() } } } diff --git a/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift b/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift index e0b4a6a..61ace0f 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift @@ -14,6 +14,5 @@ enum PartDetailUiEvent { case setQuantity(Int) case addToOutbound(partId: Int, quantity: Int) case addToCart(partId: Int, quantity: Int) - case clearError case dismiss } diff --git a/SampoomManagement/Features/Part/UI/PartDetailUiState.swift b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift index 08f3327..885cabb 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailUiState.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift @@ -11,7 +11,6 @@ struct PartDetailUiState { let part: Part? let quantity: Int let isUpdating: Bool - let updateError: String? let isOutboundSuccess: Bool let isCartSuccess: Bool @@ -19,14 +18,12 @@ struct PartDetailUiState { part: Part? = nil, quantity: Int = 1, isUpdating: Bool = false, - updateError: String? = nil, isOutboundSuccess: Bool = false, isCartSuccess: Bool = false ) { self.part = part self.quantity = quantity self.isUpdating = isUpdating - self.updateError = updateError self.isOutboundSuccess = isOutboundSuccess self.isCartSuccess = isCartSuccess } @@ -35,7 +32,6 @@ struct PartDetailUiState { part: Part?? = nil, quantity: Int? = nil, isUpdating: Bool? = nil, - updateError: String?? = nil, isOutboundSuccess: Bool? = nil, isCartSuccess: Bool? = nil ) -> PartDetailUiState { @@ -43,7 +39,6 @@ struct PartDetailUiState { part: part ?? self.part, quantity: quantity ?? self.quantity, isUpdating: isUpdating ?? self.isUpdating, - updateError: updateError ?? self.updateError, isOutboundSuccess: isOutboundSuccess ?? self.isOutboundSuccess, isCartSuccess: isCartSuccess ?? self.isCartSuccess ) diff --git a/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift index eb9388d..394d8d4 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift @@ -15,10 +15,16 @@ class PartDetailViewModel: ObservableObject { private let addOutboundUseCase: AddOutboundUseCase private let addCartUseCase: AddCartUseCase + private let globalMessageHandler: GlobalMessageHandler - init(addOutboundUseCase: AddOutboundUseCase, addCartUseCase: AddCartUseCase) { + init( + addOutboundUseCase: AddOutboundUseCase, + addCartUseCase: AddCartUseCase, + globalMessageHandler: GlobalMessageHandler + ) { self.addOutboundUseCase = addOutboundUseCase self.addCartUseCase = addCartUseCase + self.globalMessageHandler = globalMessageHandler } func onEvent(_ event: PartDetailUiEvent) { @@ -28,7 +34,6 @@ class PartDetailViewModel: ObservableObject { part: part, quantity: 1, isUpdating: false, - updateError: nil, isOutboundSuccess: false, isCartSuccess: false ) @@ -52,13 +57,10 @@ class PartDetailViewModel: ObservableObject { if part != nil { addToCart(partId: partId, quantity: quantity) } - case .clearError: - uiState = uiState.copy(updateError: .some(nil)) case .dismiss: uiState = uiState.copy( part: .some(nil), - quantity: 1, - updateError: .some(nil) + quantity: 1 ) } } @@ -66,22 +68,20 @@ class PartDetailViewModel: ObservableObject { private func addToOutbound(partId: Int, quantity: Int) { Task { await MainActor.run { - uiState = uiState.copy(isUpdating: true, updateError: nil) + uiState = uiState.copy(isUpdating: true) } do { try await addOutboundUseCase.execute(partId: partId, quantity: quantity) - await MainActor.run { uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) } print("PartDetailViewModel - addToOutbound success: \(uiState)") } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + uiState = uiState.copy(isUpdating: false) } print("PartDetailViewModel - addToOutbound error: \(error)") } @@ -91,28 +91,35 @@ class PartDetailViewModel: ObservableObject { private func addToCart(partId: Int, quantity: Int) { Task { await MainActor.run { - uiState = uiState.copy(isUpdating: true, updateError: nil) + uiState = uiState.copy(isUpdating: true) } do { try await addCartUseCase.execute(partId: partId, quantity: quantity) - await MainActor.run { uiState = uiState.copy(isUpdating: false, isCartSuccess: true) } print("PartDetailViewModel - addToCart success: \(uiState)") } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) await MainActor.run { - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + uiState = uiState.copy(isUpdating: false) } print("PartDetailViewModel - addToCart error: \(error)") } } } + /// 바텀시트가 닫힌 후 성공 메시지를 표시 (부품 리스트 화면에서 보임) + func showPendingSuccessMessage() { + if uiState.isOutboundSuccess { + globalMessageHandler.showMessage(StringResources.PartDetail.outboundSuccess, isError: false) + } else if uiState.isCartSuccess { + globalMessageHandler.showMessage(StringResources.PartDetail.cartSuccess, isError: false) + } + } + func clearSuccess() { uiState = uiState.copy(isOutboundSuccess: false, isCartSuccess: false) } diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift index 30d3aaa..d549829 100644 --- a/SampoomManagement/Features/Part/UI/PartListView.swift +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -43,7 +43,7 @@ struct PartListView: View { Spacer() EmptyView( icon: "tray", - title: "부품이 없습니다" + title: StringResources.Part.emptyPart ) .frame(height: 200) Spacer() @@ -76,6 +76,9 @@ struct PartListView: View { detailViewModel.onEvent(.initialize(selectedPart)) } .onDisappear { + // 바텀시트가 닫힌 후 성공 메시지를 부품 리스트 화면에서 표시 + detailViewModel.showPendingSuccessMessage() + detailViewModel.clearSuccess() showBottomSheet = false viewModel.onEvent(.dismissBottomSheet) } diff --git a/SampoomManagement/Features/Part/UI/SearchResultView.swift b/SampoomManagement/Features/Part/UI/SearchResultView.swift index 3624dd0..ea9b00c 100644 --- a/SampoomManagement/Features/Part/UI/SearchResultView.swift +++ b/SampoomManagement/Features/Part/UI/SearchResultView.swift @@ -31,6 +31,9 @@ struct SearchResultView: View { partDetailViewModel.onEvent(.initialize(selectedPart)) } .onDisappear { + // 바텀시트가 닫힌 후 성공 메시지를 검색 결과 화면에서 표시 + partDetailViewModel.showPendingSuccessMessage() + partDetailViewModel.clearSuccess() showBottomSheet = false viewModel.onEvent(.dismissBottomSheet) } @@ -71,7 +74,7 @@ struct SearchResultView: View { private var emptyView: some View { HStack { Spacer() - EmptyView(title: "검색 결과가 없습니다") + EmptyView(title: StringResources.SearchParts.emptyMessage) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) From b20eaaf7fa24c09578090a78bd6bda62fcb9b022 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sun, 2 Nov 2025 23:48:01 +0900 Subject: [PATCH 3/4] Update SampoomManagement/Features/Order/UI/OrderListViewModel.swift Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- SampoomManagement/Features/Order/UI/OrderListViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift index 7aa6348..596f6db 100644 --- a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift +++ b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift @@ -31,7 +31,7 @@ class OrderListViewModel: ObservableObject { case .retryOrderList: loadOrderList(page: 0, append: false) case .loadMore: - guard uiState.hasMore, !uiState.orderLoading else { return } + guard uiState.hasMore, !uiState.orderLoading, !uiState.isLoadingMore else { return } loadOrderList(page: uiState.currentPage + 1, append: true) } } From 5f0104e2aa82021b2a6ecb85297598ccb9c0cb4b Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Sun, 2 Nov 2025 23:49:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Cart/UI/CartListViewModel.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift index 8d51bbf..dbe33e8 100644 --- a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -97,9 +97,20 @@ class CartListViewModel: ObservableObject { ) } globalMessageHandler.showMessage(StringResources.Cart.orderSuccess, isError: false) + + // 로컬 상태 먼저 업데이트 (즉시 UI 반영) await MainActor.run { - deleteAllCart() + removeAllFromLocalList() + } + + // 서버 삭제 완료 후 재조회 + do { + try await deleteAllCartUseCase.execute() loadCartList() // 주문 후 장바구니 새로고침 + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + loadCartList() // 에러 발생 시에도 재조회하여 롤백 } } catch { let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription