From 498f73269ecbb067801f10968445e6ae866221b4 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Mon, 20 Oct 2025 10:22:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20=EC=B6=9C=EA=B3=A0=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D,=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88,=20=EC=B6=9C?= =?UTF-8?q?=EA=B3=A0=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81,=20=EC=9E=A5?= =?UTF-8?q?=EB=B0=94=EA=B5=AC=EB=8B=88=20=EB=8B=B4=EA=B8=B0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 43 +-- .../Core/DI/AppDependencies.swift | 61 ++++ .../Core/Network/APIResponse.swift | 5 +- .../Core/Network/NetworkError.swift | 3 + .../Core/Network/NetworkManager.swift | 41 +-- .../Core/Resources/StringResources.swift | 2 +- .../Core/UI/Components/CommonButton.swift | 15 +- .../Auth/Data/Remote/API/AuthAPI.swift | 92 +++--- .../Data/Repository/AuthRepositoryImpl.swift | 4 +- .../Cart/Data/Mappers/CartMappers.swift | 27 ++ .../Cart/Data/Remote/API/CartAPI.swift | 79 ++++++ .../Data/Remote/DTO/AddCartRequestDto.swift | 14 + .../Cart/Data/Remote/DTO/CartDto.swift | 29 ++ .../Remote/DTO/UpdateCartRequestDto.swift | 13 + .../Data/Repository/CartRepositoryImpl.swift | 41 +++ .../Features/Cart/Domain/Models/Cart.swift | 29 ++ .../Cart/Domain/Models/CartList.swift | 25 ++ .../Domain/Repository/CartRepository.swift | 17 ++ .../Cart/Domain/UseCase/AddCartUseCase.swift | 21 ++ .../Domain/UseCase/DeleteAllCartUseCase.swift | 21 ++ .../Domain/UseCase/DeleteCartUseCase.swift | 21 ++ .../Cart/Domain/UseCase/GetCartUseCase.swift | 21 ++ .../UseCase/UpdateCartQuantityUseCase.swift | 21 ++ .../Features/Cart/UI/CartListUiEvent.swift | 20 ++ .../Features/Cart/UI/CartListUiState.swift | 67 +++++ .../Features/Cart/UI/CartListView.swift | 226 +++++++++++++++ .../Features/Cart/UI/CartListViewModel.swift | 220 ++++++++++++++ .../Data/Mappers/OutboundMappers.swift | 40 +++ .../Data/Remote/API/OutboundAPI.swift | 98 +++++++ .../Remote/DTO/AddOutboundRequestDto.swift | 13 + .../Data/Remote/DTO/OutboundDto.swift | 28 ++ .../Remote/DTO/UpdateOutboundRequestDto.swift | 12 + .../Repository/OutboundRepositoryImpl.swift | 44 +++ .../Outbound/Domain/Models/Outbound.swift | 28 ++ .../Outbound/Domain/Models/OutboundList.swift | 24 ++ .../Repository/OutboundRepository.swift | 17 ++ .../Domain/UseCase/AddOutboundUseCase.swift | 20 ++ .../UseCase/DeleteAllOutboundUseCase.swift | 20 ++ .../UseCase/DeleteOutboundUseCase.swift | 20 ++ .../Domain/UseCase/GetOutboundUseCase.swift | 20 ++ .../UseCase/ProcessOutboundUseCase.swift | 20 ++ .../UpdateOutboundQuantityUseCase.swift | 20 ++ .../Outbound/UI/OutboundListUiEvent.swift | 19 ++ .../Outbound/UI/OutboundListUiState.swift | 66 +++++ .../Outbound/UI/OutboundListView.swift | 268 ++++++++++++++++++ .../Outbound/UI/OutboundListViewModel.swift | 229 +++++++++++++++ .../Part/Data/Remote/API/PartAPI.swift | 63 ++-- .../Part/UI/PartDetailBottomSheetView.swift | 212 ++++++++++++++ .../Features/Part/UI/PartDetailUiEvent.swift | 19 ++ .../Features/Part/UI/PartDetailUiState.swift | 51 ++++ .../Part/UI/PartDetailViewModel.swift | 113 ++++++++ .../Features/Part/UI/PartListUiEvent.swift | 2 + .../Features/Part/UI/PartListUiState.swift | 11 +- .../Features/Part/UI/PartListView.swift | 82 ++++-- .../Features/Part/UI/PartListViewModel.swift | 16 +- .../Features/Part/UI/PartView.swift | 24 +- .../Contents.json | 2 +- .../outbound.svg} | 0 58 files changed, 2578 insertions(+), 201 deletions(-) create mode 100644 SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift create mode 100644 SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift create mode 100644 SampoomManagement/Features/Cart/Data/Remote/DTO/AddCartRequestDto.swift create mode 100644 SampoomManagement/Features/Cart/Data/Remote/DTO/CartDto.swift create mode 100644 SampoomManagement/Features/Cart/Data/Remote/DTO/UpdateCartRequestDto.swift create mode 100644 SampoomManagement/Features/Cart/Data/Repository/CartRepositoryImpl.swift create mode 100644 SampoomManagement/Features/Cart/Domain/Models/Cart.swift create mode 100644 SampoomManagement/Features/Cart/Domain/Models/CartList.swift create mode 100644 SampoomManagement/Features/Cart/Domain/Repository/CartRepository.swift create mode 100644 SampoomManagement/Features/Cart/Domain/UseCase/AddCartUseCase.swift create mode 100644 SampoomManagement/Features/Cart/Domain/UseCase/DeleteAllCartUseCase.swift create mode 100644 SampoomManagement/Features/Cart/Domain/UseCase/DeleteCartUseCase.swift create mode 100644 SampoomManagement/Features/Cart/Domain/UseCase/GetCartUseCase.swift create mode 100644 SampoomManagement/Features/Cart/Domain/UseCase/UpdateCartQuantityUseCase.swift create mode 100644 SampoomManagement/Features/Cart/UI/CartListUiEvent.swift create mode 100644 SampoomManagement/Features/Cart/UI/CartListUiState.swift create mode 100644 SampoomManagement/Features/Cart/UI/CartListView.swift create mode 100644 SampoomManagement/Features/Cart/UI/CartListViewModel.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Remote/DTO/AddOutboundRequestDto.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Remote/DTO/OutboundDto.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Remote/DTO/UpdateOutboundRequestDto.swift create mode 100644 SampoomManagement/Features/Outbound/Data/Repository/OutboundRepositoryImpl.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/Models/OutboundList.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/Repository/OutboundRepository.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/AddOutboundUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/DeleteAllOutboundUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/DeleteOutboundUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/GetOutboundUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/ProcessOutboundUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/Domain/UseCase/UpdateOutboundQuantityUseCase.swift create mode 100644 SampoomManagement/Features/Outbound/UI/OutboundListUiEvent.swift create mode 100644 SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift create mode 100644 SampoomManagement/Features/Outbound/UI/OutboundListView.swift create mode 100644 SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift create mode 100644 SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift create mode 100644 SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift create mode 100644 SampoomManagement/Features/Part/UI/PartDetailUiState.swift create mode 100644 SampoomManagement/Features/Part/UI/PartDetailViewModel.swift rename SampoomManagement/Resources/Assets.xcassets/{delivery.imageset => outbound.imageset}/Contents.json (77%) rename SampoomManagement/Resources/Assets.xcassets/{delivery.imageset/delivery.svg => outbound.imageset/outbound.svg} (100%) diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index 0b70a1d..a77d473 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -8,7 +8,7 @@ import SwiftUI enum Tabs { - case dashboard, delivery, cart, orders, parts + case dashboard, outbound, cart, orders, parts } struct ContentView: View { @@ -52,50 +52,26 @@ struct ContentView: View { } } - // Delivery 탭 (임시) - Tab(value: .delivery) { + // Outbound 탭 + Tab(value: .outbound) { NavigationStack { - VStack(spacing: 20) { - Spacer() - Text(StringResources.Tabs.delivery) - .font(.largeTitle) - .fontWeight(.bold) - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() - } - .navigationTitle(StringResources.Tabs.delivery) + OutboundListView(viewModel: dependencies.makeOutboundListViewModel()) } } label: { Label { - Text(StringResources.Tabs.delivery) + Text(StringResources.Tabs.outbound) .font(.gmarketSubheadline) } icon: { - Image("delivery") + Image("outbound") .renderingMode(.template) .foregroundStyle(Color.text) } } - // Cart 탭 (임시) + // Cart 탭 Tab(value: .cart) { NavigationStack { - VStack(spacing: 20) { - Spacer() - Text(StringResources.Tabs.cart) - .font(.largeTitle) - .fontWeight(.bold) - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() - } - .navigationTitle(StringResources.Tabs.cart) + CartListView(viewModel: dependencies.makeCartListViewModel()) } } label: { Label { @@ -150,7 +126,8 @@ struct ContentView: View { viewModel: PartListViewModel( getPartUseCase: dependencies.getPartUseCase, groupId: groupId - ) + ), + dependencies: dependencies ) } } diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index 9917d63..ff20726 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -27,6 +27,25 @@ class AppDependencies { let getGroupUseCase: GetGroupUseCase let getPartUseCase: GetPartUseCase + // MARK: - Outbound + let outboundAPI: OutboundAPI + let outboundRepository: OutboundRepository + let getOutboundUseCase: GetOutboundUseCase + let addOutboundUseCase: AddOutboundUseCase + let deleteOutboundUseCase: DeleteOutboundUseCase + let deleteAllOutboundUseCase: DeleteAllOutboundUseCase + let processOutboundUseCase: ProcessOutboundUseCase + let updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase + + // MARK: - Cart + let cartAPI: CartAPI + let cartRepository: CartRepository + let getCartUseCase: GetCartUseCase + let addCartUseCase: AddCartUseCase + let deleteCartUseCase: DeleteCartUseCase + let deleteAllCartUseCase: DeleteAllCartUseCase + let updateCartQuantityUseCase: UpdateCartQuantityUseCase + init() { // Core networkManager = NetworkManager() @@ -47,6 +66,25 @@ class AppDependencies { getCategoryUseCase = GetCategoryUseCase(repository: partRepository) getGroupUseCase = GetGroupUseCase(repository: partRepository) getPartUseCase = GetPartUseCase(repository: partRepository) + + // Outbound + outboundAPI = OutboundAPI(networkManager: networkManager) + outboundRepository = OutboundRepositoryImpl(api: outboundAPI) + getOutboundUseCase = GetOutboundUseCase(repository: outboundRepository) + addOutboundUseCase = AddOutboundUseCase(repository: outboundRepository) + deleteOutboundUseCase = DeleteOutboundUseCase(repository: outboundRepository) + deleteAllOutboundUseCase = DeleteAllOutboundUseCase(repository: outboundRepository) + processOutboundUseCase = ProcessOutboundUseCase(repository: outboundRepository) + updateOutboundQuantityUseCase = UpdateOutboundQuantityUseCase(repository: outboundRepository) + + // Cart + cartAPI = CartAPI(networkManager: networkManager) + cartRepository = CartRepositoryImpl(api: cartAPI) + getCartUseCase = GetCartUseCase(repository: cartRepository) + addCartUseCase = AddCartUseCase(repository: cartRepository) + deleteCartUseCase = DeleteCartUseCase(repository: cartRepository) + deleteAllCartUseCase = DeleteAllCartUseCase(repository: cartRepository) + updateCartQuantityUseCase = UpdateCartQuantityUseCase(repository: cartRepository) } // MARK: - ViewModel Factories @@ -71,5 +109,28 @@ class AppDependencies { getPartUseCase: getPartUseCase, groupId: groupId ) } + + func makePartDetailViewModel() -> PartDetailViewModel { + return PartDetailViewModel(addOutboundUseCase: addOutboundUseCase, addCartUseCase: addCartUseCase) + } + + func makeOutboundListViewModel() -> OutboundListViewModel { + return OutboundListViewModel( + getOutboundUseCase: getOutboundUseCase, + processOutboundUseCase: processOutboundUseCase, + updateOutboundQuantityUseCase: updateOutboundQuantityUseCase, + deleteOutboundUseCase: deleteOutboundUseCase, + deleteAllOutboundUseCase: deleteAllOutboundUseCase + ) + } + + func makeCartListViewModel() -> CartListViewModel { + return CartListViewModel( + getCartUseCase: getCartUseCase, + updateCartQuantityUseCase: updateCartQuantityUseCase, + deleteCartUseCase: deleteCartUseCase, + deleteAllCartUseCase: deleteAllCartUseCase + ) + } } diff --git a/SampoomManagement/Core/Network/APIResponse.swift b/SampoomManagement/Core/Network/APIResponse.swift index 907cf15..df4cfb8 100644 --- a/SampoomManagement/Core/Network/APIResponse.swift +++ b/SampoomManagement/Core/Network/APIResponse.swift @@ -11,5 +11,8 @@ struct APIResponse: Codable { let status: Int let success: Bool let message: String - let data: T + let data: T? +} + +struct EmptyResponse: Codable { } diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index 29a74b2..0dd0eaa 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -34,6 +34,7 @@ enum AuthError: Error, LocalizedError { case tokenSaveFailed(Error) case invalidCredentials case networkError(Error) + case invalidResponse var errorDescription: String? { switch self { @@ -43,6 +44,8 @@ enum AuthError: Error, LocalizedError { return "잘못된 인증 정보입니다" case .networkError(let error): return "네트워크 오류: \(error.localizedDescription)" + case .invalidResponse: + return "잘못된 응답입니다" } } } diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index 917b8ed..94c04f0 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -19,31 +19,32 @@ class NetworkManager { endpoint: String, method: HTTPMethod = .get, parameters: Parameters? = nil, - responseType: T.Type, - completion: @escaping (Result, NetworkError>) -> Void - ) { + responseType: T.Type + ) async throws -> APIResponse { let url = baseURL + endpoint - - AF.request( - url, - method: method, - parameters: parameters, - encoding: JSONEncoding.default - ) - .responseData { response in - switch response.result { - case .success(let data): - Task { @MainActor in + + return try await withCheckedThrowingContinuation { continuation in + AF.request( + url, + method: method, + parameters: parameters, + encoding: JSONEncoding.default + ) + .responseData { response in + switch response.result { + case .success(let data): do { + print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") let apiResponse = try JSONDecoder().decode(APIResponse.self, from: data) - completion(.success(apiResponse)) + print("NetworkManager - Decoded response: \(apiResponse)") + continuation.resume(returning: apiResponse) } catch { - completion(.failure(.decodingError(error))) + print("NetworkManager - Decoding error: \(error)") + continuation.resume(throwing: NetworkError.decodingError(error)) } - } - case .failure(let error): - Task { @MainActor in - completion(.failure(.networkError(error))) + case .failure(let error): + print("NetworkManager - Network error: \(error)") + continuation.resume(throwing: NetworkError.networkError(error)) } } } diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 783d612..3c4e1a5 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -17,7 +17,7 @@ struct StringResources { // MARK: - Tabs struct Tabs { static let dashboard = "대시보드" - static let delivery = "출고목록" + static let outbound = "출고목록" static let cart = "장바구니" static let orders = "주문관리" static let parts = "부품조회" diff --git a/SampoomManagement/Core/UI/Components/CommonButton.swift b/SampoomManagement/Core/UI/Components/CommonButton.swift index a86c0b0..843a71d 100644 --- a/SampoomManagement/Core/UI/Components/CommonButton.swift +++ b/SampoomManagement/Core/UI/Components/CommonButton.swift @@ -42,6 +42,7 @@ struct CommonButton: View { let type: ButtonType let size: ButtonSize let icon: String? + let customIcon: String? let iconPosition: IconPosition let isEnabled: Bool let backgroundColor: Color? @@ -54,6 +55,7 @@ struct CommonButton: View { type: ButtonType = .filled, size: ButtonSize = .medium, icon: String? = nil, + customIcon: String? = nil, iconPosition: IconPosition = .leading, isEnabled: Bool = true, backgroundColor: Color? = nil, @@ -65,6 +67,7 @@ struct CommonButton: View { self.type = type self.size = size self.icon = icon + self.customIcon = customIcon self.iconPosition = iconPosition self.isEnabled = isEnabled self.backgroundColor = backgroundColor @@ -76,7 +79,11 @@ struct CommonButton: View { var body: some View { Button(action: action) { HStack(spacing: 8) { - if let icon = icon, iconPosition == .leading { + if let customIcon = customIcon, iconPosition == .leading { + Image(customIcon) + .renderingMode(.template) + .font(size.font) + } else if let icon = icon, iconPosition == .leading { Image(systemName: icon) .font(size.font) } @@ -84,7 +91,11 @@ struct CommonButton: View { Text(title) .font(.gmarketBody) - if let icon = icon, iconPosition == .trailing { + if let customIcon = customIcon, iconPosition == .trailing { + Image(customIcon) + .renderingMode(.template) + .font(size.font) + } else if let icon = icon, iconPosition == .trailing { Image(systemName: icon) .font(size.font) } diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift index 59d35d5..7f2c914 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -17,28 +17,19 @@ class AuthAPI { // 로그인 func login(email: String, password: String) async throws -> APIResponse { - return try await withCheckedThrowingContinuation { continuation in - let requestDTO = LoginRequestDTO(email: email, password: password) - - let parameters: [String: Any] = [ - "email": requestDTO.email, - "password": requestDTO.password - ] - - networkManager.request( - endpoint: "auth/login", - method: .post, - parameters: parameters, - responseType: LoginResponseDTO.self - ) { result in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + let requestDTO = LoginRequestDTO(email: email, password: password) + + let parameters: [String: Any] = [ + "email": requestDTO.email, + "password": requestDTO.password + ] + + return try await networkManager.request( + endpoint: "auth/login", + method: .post, + parameters: parameters, + responseType: LoginResponseDTO.self + ) } // 회원가입 @@ -50,39 +41,30 @@ class AuthAPI { userName: String, position: String ) async throws -> APIResponse { - return try await withCheckedThrowingContinuation { continuation in - let requestDTO = SignupRequestDTO( - userName: userName, - workspace: workspace, - branch: branch, - position: position, - email: email, - password: password - ) - - let parameters: [String: Any] = [ - "email": requestDTO.email, - "password": requestDTO.password, - "workspace": requestDTO.workspace, - "branch": requestDTO.branch, - "userName": requestDTO.userName, - "position": requestDTO.position - ] - - networkManager.request( - endpoint: "auth/signup", - method: .post, - parameters: parameters, - responseType: SignupResponseDTO.self - ) { result in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + let requestDTO = SignupRequestDTO( + userName: userName, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + + let parameters: [String: Any] = [ + "email": requestDTO.email, + "password": requestDTO.password, + "workspace": requestDTO.workspace, + "branch": requestDTO.branch, + "userName": requestDTO.userName, + "position": requestDTO.position + ] + + return try await networkManager.request( + endpoint: "auth/signup", + method: .post, + parameters: parameters, + responseType: SignupResponseDTO.self + ) } } diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift index 69f534b..80bc02e 100644 --- a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -40,7 +40,9 @@ class AuthRepositoryImpl: AuthRepository { func signIn(email: String, password: String) async throws -> User { let response = try await api.login(email: email, password: password) - let dto = response.data + guard let dto = response.data else { + throw AuthError.invalidResponse + } do { try preferences.saveToken( diff --git a/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift b/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift new file mode 100644 index 0000000..34db85a --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift @@ -0,0 +1,27 @@ +// +// CartMappers.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +extension CartDto { + func toModel() -> Cart { + return Cart(categoryId: categoryId, categoryName: categoryName, groups: groups.map { $0.toModel() }) + } +} + +extension CartGroupDto { + func toModel() -> CartGroup { + return CartGroup(groupId: groupId, groupName: groupName, parts: parts.map { $0.toModel() }) + } +} + +extension CartPartDto { + func toModel() -> CartPart { + return CartPart(cartItemId: cartItemId, partId: partId, code: code, name: name, quantity: quantity) + } +} + diff --git a/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift new file mode 100644 index 0000000..01fe5b6 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift @@ -0,0 +1,79 @@ +// +// CartAPI.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation +import Alamofire + +class CartAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 장바구니 목록 조회 + func getCartList() async throws -> [CartDto] { + let response = try await networkManager.request( + endpoint: "agency/1/cart", + method: .get, + responseType: [CartDto].self + ) + print("CartAPI - getCartList response: \(response)") + return response.data ?? [] + } + + // 장바구니에 부품 추가 + func addCart(request: AddCartRequestDto) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/cart", + method: .post, + parameters: request.toDictionary(), + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 항목 삭제 + func deleteCart(cartItemId: Int) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/cart/\(cartItemId)", + method: .delete, + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 수량 변경 + func updateCart(cartItemId: Int, request: UpdateCartRequestDto) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/cart/\(cartItemId)", + method: .put, + parameters: request.toDictionary(), + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 전체 비우기 + func deleteAllCart() async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/cart/clear", + method: .delete, + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } +} + diff --git a/SampoomManagement/Features/Cart/Data/Remote/DTO/AddCartRequestDto.swift b/SampoomManagement/Features/Cart/Data/Remote/DTO/AddCartRequestDto.swift new file mode 100644 index 0000000..82395b7 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/AddCartRequestDto.swift @@ -0,0 +1,14 @@ +// +// AddCartRequestDto.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +struct AddCartRequestDto: Codable { + let partId: Int + let quantity: Int +} + diff --git a/SampoomManagement/Features/Cart/Data/Remote/DTO/CartDto.swift b/SampoomManagement/Features/Cart/Data/Remote/DTO/CartDto.swift new file mode 100644 index 0000000..2d054c6 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/CartDto.swift @@ -0,0 +1,29 @@ +// +// CartDto.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +struct CartDto: Codable { + let categoryId: Int + let categoryName: String + let groups: [CartGroupDto] +} + +struct CartGroupDto: Codable { + let groupId: Int + let groupName: String + let parts: [CartPartDto] +} + +struct CartPartDto: Codable { + let cartItemId: Int + let partId: Int + let code: String + let name: String + let quantity: Int +} + diff --git a/SampoomManagement/Features/Cart/Data/Remote/DTO/UpdateCartRequestDto.swift b/SampoomManagement/Features/Cart/Data/Remote/DTO/UpdateCartRequestDto.swift new file mode 100644 index 0000000..4698fe0 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/UpdateCartRequestDto.swift @@ -0,0 +1,13 @@ +// +// UpdateCartRequestDto.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +struct UpdateCartRequestDto: Codable { + let quantity: Int +} + diff --git a/SampoomManagement/Features/Cart/Data/Repository/CartRepositoryImpl.swift b/SampoomManagement/Features/Cart/Data/Repository/CartRepositoryImpl.swift new file mode 100644 index 0000000..5e11829 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Repository/CartRepositoryImpl.swift @@ -0,0 +1,41 @@ +// +// CartRepositoryImpl.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class CartRepositoryImpl: CartRepository { + private let api: CartAPI + + init(api: CartAPI) { + self.api = api + } + + func getCartList() async throws -> CartList { + let data: [CartDto] = try await api.getCartList() + let cartItems = data.map { $0.toModel() } + return CartList(items: cartItems) + } + + func addCart(partId: Int, quantity: Int) async throws { + let request = AddCartRequestDto(partId: partId, quantity: quantity) + try await api.addCart(request: request) + } + + func deleteCart(cartItemId: Int) async throws { + try await api.deleteCart(cartItemId: cartItemId) + } + + func deleteAllCart() async throws { + try await api.deleteAllCart() + } + + func updateCartQuantity(cartItemId: Int, quantity: Int) async throws { + let request = UpdateCartRequestDto(quantity: quantity) + try await api.updateCart(cartItemId: cartItemId, request: request) + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/Models/Cart.swift b/SampoomManagement/Features/Cart/Domain/Models/Cart.swift new file mode 100644 index 0000000..b5dfbc3 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Models/Cart.swift @@ -0,0 +1,29 @@ +// +// Cart.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +struct Cart: Equatable { + let categoryId: Int + let categoryName: String + let groups: [CartGroup] +} + +struct CartGroup: Equatable { + let groupId: Int + let groupName: String + let parts: [CartPart] +} + +struct CartPart: Equatable { + let cartItemId: Int + let partId: Int + let code: String + let name: String + let quantity: Int +} + diff --git a/SampoomManagement/Features/Cart/Domain/Models/CartList.swift b/SampoomManagement/Features/Cart/Domain/Models/CartList.swift new file mode 100644 index 0000000..cad0fd2 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Models/CartList.swift @@ -0,0 +1,25 @@ +// +// CartList.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +struct CartList: Equatable { + let items: [Cart] + let totalCount: Int + let isEmpty: Bool + + init(items: [Cart]) { + self.items = items + self.totalCount = items.count + self.isEmpty = items.isEmpty + } + + static func empty() -> CartList { + return CartList(items: []) + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/Repository/CartRepository.swift b/SampoomManagement/Features/Cart/Domain/Repository/CartRepository.swift new file mode 100644 index 0000000..a3401a1 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Repository/CartRepository.swift @@ -0,0 +1,17 @@ +// +// CartRepository.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +protocol CartRepository { + func getCartList() async throws -> CartList + func addCart(partId: Int, quantity: Int) async throws -> Void + func deleteCart(cartItemId: Int) async throws -> Void + func deleteAllCart() async throws -> Void + func updateCartQuantity(cartItemId: Int, quantity: Int) async throws -> Void +} + diff --git a/SampoomManagement/Features/Cart/Domain/UseCase/AddCartUseCase.swift b/SampoomManagement/Features/Cart/Domain/UseCase/AddCartUseCase.swift new file mode 100644 index 0000000..c3f2251 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/AddCartUseCase.swift @@ -0,0 +1,21 @@ +// +// AddCartUseCase.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class AddCartUseCase { + private let repository: CartRepository + + init(repository: CartRepository) { + self.repository = repository + } + + func execute(partId: Int, quantity: Int) async throws { + try await repository.addCart(partId: partId, quantity: quantity) + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/UseCase/DeleteAllCartUseCase.swift b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteAllCartUseCase.swift new file mode 100644 index 0000000..d1c5be5 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteAllCartUseCase.swift @@ -0,0 +1,21 @@ +// +// DeleteAllCartUseCase.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class DeleteAllCartUseCase { + private let repository: CartRepository + + init(repository: CartRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.deleteAllCart() + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/UseCase/DeleteCartUseCase.swift b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteCartUseCase.swift new file mode 100644 index 0000000..2fe9b53 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteCartUseCase.swift @@ -0,0 +1,21 @@ +// +// DeleteCartUseCase.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class DeleteCartUseCase { + private let repository: CartRepository + + init(repository: CartRepository) { + self.repository = repository + } + + func execute(cartItemId: Int) async throws { + try await repository.deleteCart(cartItemId: cartItemId) + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/UseCase/GetCartUseCase.swift b/SampoomManagement/Features/Cart/Domain/UseCase/GetCartUseCase.swift new file mode 100644 index 0000000..9e2d215 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/GetCartUseCase.swift @@ -0,0 +1,21 @@ +// +// GetCartUseCase.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class GetCartUseCase { + private let repository: CartRepository + + init(repository: CartRepository) { + self.repository = repository + } + + func execute() async throws -> CartList { + return try await repository.getCartList() + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/UseCase/UpdateCartQuantityUseCase.swift b/SampoomManagement/Features/Cart/Domain/UseCase/UpdateCartQuantityUseCase.swift new file mode 100644 index 0000000..0ec3972 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/UpdateCartQuantityUseCase.swift @@ -0,0 +1,21 @@ +// +// UpdateCartQuantityUseCase.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +class UpdateCartQuantityUseCase { + private let repository: CartRepository + + init(repository: CartRepository) { + self.repository = repository + } + + func execute(cartItemId: Int, quantity: Int) async throws { + try await repository.updateCartQuantity(cartItemId: cartItemId, quantity: quantity) + } +} + diff --git a/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift b/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift new file mode 100644 index 0000000..faf413b --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift @@ -0,0 +1,20 @@ +// +// CartListUiEvent.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation + +enum CartListUiEvent { + case loadCartList + case retryCartList + case processOrder + case updateQuantity(cartItemId: Int, quantity: Int) + case deleteCart(cartItemId: Int) + case deleteAllCart + case clearUpdateError + case clearDeleteError +} + diff --git a/SampoomManagement/Features/Cart/UI/CartListUiState.swift b/SampoomManagement/Features/Cart/UI/CartListUiState.swift new file mode 100644 index 0000000..3cf7dd0 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListUiState.swift @@ -0,0 +1,67 @@ +// +// CartListUiState.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +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 + + 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 + ) { + 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 + } + + 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 + ) -> 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 + ) + } +} + diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift new file mode 100644 index 0000000..dd3989c --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -0,0 +1,226 @@ +// +// CartListView.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import SwiftUI +import Toast + +struct CartListView: View { + @ObservedObject var viewModel: CartListViewModel + @State private var showEmptyCartDialog = false + @State private var showConfirmDialog = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Content + if viewModel.uiState.cartLoading { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } else if let error = viewModel.uiState.cartError { + HStack { + Spacer() + ErrorView( + error: error, + onRetry: { + viewModel.onEvent(.retryCartList) + } + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.uiState.cartList.isEmpty { + HStack { + Spacer() + EmptyView(title: "장바구니가 비어있습니다") + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack(alignment: .bottom) { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.uiState.cartList.indices, id: \.self) { categoryIndex in + let category = viewModel.uiState.cartList[categoryIndex] + ForEach(category.groups.indices, id: \.self) { groupIndex in + let group = category.groups[groupIndex] + CartSection( + categoryName: category.categoryName, + groupName: group.groupName, + parts: group.parts, + isUpdating: viewModel.uiState.isUpdating, + isDeleting: viewModel.uiState.isDeleting, + onEvent: { event in + viewModel.onEvent(event) + } + ) + } + } + Spacer() + .frame(height: 100) + } + .padding(.horizontal, 16) + } + + // 주문하기 버튼 + VStack { + Spacer() + CommonButton("부품 주문", backgroundColor: .accentColor, textColor: .white) { + showConfirmDialog = true + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + } + } + .navigationTitle("장바구니") + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !viewModel.uiState.cartLoading && viewModel.uiState.cartError == nil && !viewModel.uiState.cartList.isEmpty { + Button("장바구니 비우기") { + showEmptyCartDialog = true + } + .foregroundColor(.red) + } + } + } + .background(Color.background) + .alert("장바구니 비우기", isPresented: $showEmptyCartDialog) { + Button("확인") { + viewModel.onEvent(.deleteAllCart) + } + Button("취소", role: .cancel) { } + } message: { + Text("장바구니를 비우시겠습니까?") + } + .alert("주문 확인", isPresented: $showConfirmDialog) { + Button("확인") { + viewModel.onEvent(.processOrder) + } + Button("취소", role: .cancel) { } + } message: { + Text("선택하신 부품을 주문하시겠습니까?") + } + .onAppear { + viewModel.onEvent(.loadCartList) + } + .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in + if newValue { + Toast.text("주문 성공!").show() + viewModel.clearSuccess() + } + } + .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in + if let error = newValue { + Toast.text("수량 업데이트 에러: \(error)").show() + viewModel.onEvent(.clearUpdateError) + } + } + .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in + if let error = newValue { + Toast.text("삭제 에러: \(error)").show() + viewModel.onEvent(.clearDeleteError) + } + } + } +} + +struct CartSection: View { + let categoryName: String + let groupName: String + let parts: [CartPart] + let isUpdating: Bool + let isDeleting: Bool + let onEvent: (CartListUiEvent) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(categoryName) > \(groupName)") + .font(.gmarketTitle3) + .foregroundColor(.text) + + ForEach(parts, id: \.cartItemId) { part in + CartPartItem( + part: part, + isUpdating: isUpdating, + isDeleting: isDeleting, + onEvent: onEvent + ) + } + } + } +} + +struct CartPartItem: View { + let part: CartPart + let isUpdating: Bool + let isDeleting: Bool + let onEvent: (CartListUiEvent) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + CommonButton("", icon: "trash", backgroundColor: .clear, textColor: .red) { + onEvent(.deleteCart(cartItemId: part.cartItemId)) + } + .frame(width: 44, height: 44) + .disabled(isDeleting) + } + .padding(16) + + // 수량 조절 + HStack { + Text("수량") + .font(.gmarketBody) + .foregroundColor(.text) + + Spacer() + + HStack(spacing: 8) { + CommonButton("-", backgroundColor: .disable, textColor: .text) { + if part.quantity > 1 { + onEvent(.updateQuantity(cartItemId: part.cartItemId, quantity: part.quantity - 1)) + } + } + .frame(width: 50, height: 44) + .disabled(isUpdating || part.quantity <= 1) + + Text("\(part.quantity)") + .font(.gmarketTitle3) + .foregroundColor(.text) + .frame(width: 100) + .multilineTextAlignment(.center) + + CommonButton("+", backgroundColor: .disable, textColor: .text) { + onEvent(.updateQuantity(cartItemId: part.cartItemId, quantity: part.quantity + 1)) + } + .frame(width: 50, height: 44) + .disabled(isUpdating) + } + } + .padding(16) + } + .background(Color.backgroundCard) + .cornerRadius(12) + } +} +} diff --git a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift new file mode 100644 index 0000000..bd89d26 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -0,0 +1,220 @@ +// +// CartListViewModel.swift +// SampoomManagement +// +// Created by AI Assistant on 10/20/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class CartListViewModel: ObservableObject { + @Published var uiState = CartListUiState() + + private let getCartUseCase: GetCartUseCase + private let updateCartQuantityUseCase: UpdateCartQuantityUseCase + private let deleteCartUseCase: DeleteCartUseCase + private let deleteAllCartUseCase: DeleteAllCartUseCase + // TODO: ProcessOrderUseCase 구현 후 주입 + + private var errorLabel: String = "" + + init( + getCartUseCase: GetCartUseCase, + updateCartQuantityUseCase: UpdateCartQuantityUseCase, + deleteCartUseCase: DeleteCartUseCase, + deleteAllCartUseCase: DeleteAllCartUseCase + ) { + self.getCartUseCase = getCartUseCase + self.updateCartQuantityUseCase = updateCartQuantityUseCase + self.deleteCartUseCase = deleteCartUseCase + self.deleteAllCartUseCase = deleteAllCartUseCase + } + + func bindLabel(error: String) { + errorLabel = error + } + + func onEvent(_ event: CartListUiEvent) { + switch event { + case .loadCartList: + loadCartList() + case .retryCartList: + loadCartList() + case .processOrder: + processOrder() + case .updateQuantity(let cartItemId, let quantity): + updateQuantity(cartItemId: cartItemId, quantity: quantity) + case .deleteCart(let cartItemId): + deleteCart(cartItemId: cartItemId) + case .deleteAllCart: + deleteAllCart() + case .clearUpdateError: + uiState = uiState.copy(updateError: nil) + case .clearDeleteError: + uiState = uiState.copy(deleteError: nil) + } + } + + private func loadCartList() { + Task { + uiState = uiState.copy(cartLoading: true, cartError: nil) + + do { + let cartList = try await getCartUseCase.execute() + + uiState = uiState.copy( + cartList: cartList.items, + cartLoading: false, + cartError: nil + ) + } catch { + uiState = uiState.copy( + cartLoading: false, + cartError: error.localizedDescription + ) + } + print("CartListViewModel - loadCartList: \(uiState)") + } + } + + // TODO: 주문 생성 UseCase 구현 후 수정 + private func processOrder() { + Task { + // Placeholder implementation + print("CartListViewModel - processOrder called") + // TODO: ProcessOrderUseCase 구현 후 사용 + } + } + + private func updateQuantity(cartItemId: Int, quantity: Int) { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + updateLocalQuantity(cartItemId: cartItemId, quantity: quantity) + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isUpdating: true, updateError: nil) + + do { + try await updateCartQuantityUseCase.execute(cartItemId: cartItemId, quantity: quantity) + uiState = uiState.copy(isUpdating: false) + print("CartListViewModel - updateQuantity success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadCartList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + print("CartListViewModel - updateQuantity error: \(error)") + } + } + } + + private func updateLocalQuantity(cartItemId: Int, quantity: Int) { + let updatedList = uiState.cartList.map { category in + Cart( + categoryId: category.categoryId, + categoryName: category.categoryName, + groups: category.groups.map { group in + CartGroup( + groupId: group.groupId, + groupName: group.groupName, + parts: group.parts.map { part in + if part.cartItemId == cartItemId { + CartPart( + cartItemId: part.cartItemId, + partId: part.partId, + code: part.code, + name: part.name, + quantity: quantity + ) + } else { + part + } + } + ) + } + ) + } + uiState = uiState.copy(cartList: updatedList) + } + + private func deleteCart(cartItemId: Int) { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + removeFromLocalList(cartItemId: cartItemId) + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + + do { + try await deleteCartUseCase.execute(cartItemId: cartItemId) + uiState = uiState.copy(isDeleting: false) + print("CartListViewModel - deleteCart success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadCartList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + print("CartListViewModel - deleteCart error: \(error)") + } + } + } + + private func removeFromLocalList(cartItemId: Int) { + let updatedList = uiState.cartList.compactMap { category in + let updatedGroups = category.groups.compactMap { group in + let filteredParts = group.parts.filter { $0.cartItemId != cartItemId } + return filteredParts.isEmpty ? nil : CartGroup( + groupId: group.groupId, + groupName: group.groupName, + parts: filteredParts + ) + } + return updatedGroups.isEmpty ? nil : Cart( + categoryId: category.categoryId, + categoryName: category.categoryName, + groups: updatedGroups + ) + } + uiState = uiState.copy(cartList: updatedList) + } + + private func deleteAllCart() { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + removeAllFromLocalList() + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + + do { + try await deleteAllCartUseCase.execute() + uiState = uiState.copy(isDeleting: false) + print("CartListViewModel - deleteAllCart success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadCartList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + print("CartListViewModel - deleteAllCart error: \(error)") + } + } + } + + private func removeAllFromLocalList() { + uiState = uiState.copy(cartList: []) + } + + func clearSuccess() { + uiState = uiState.copy(isOrderSuccess: false) + } +} + diff --git a/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift b/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift new file mode 100644 index 0000000..7a68582 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift @@ -0,0 +1,40 @@ +// +// OutboundMappers.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +extension OutboundDto { + func toModel() -> Outbound { + return Outbound( + categoryId: categoryId, + categoryName: categoryName, + groups: groups.map { $0.toModel() } + ) + } +} + +extension OutboundGroupDto { + func toModel() -> OutboundGroup { + return OutboundGroup( + groupId: groupId, + groupName: groupName, + parts: parts.map { $0.toModel() } + ) + } +} + +extension OutboundPartDto { + func toModel() -> OutboundPart { + return OutboundPart( + outboundId: outboundId, + partId: partId, + code: code, + name: name, + quantity: quantity + ) + } +} diff --git a/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift new file mode 100644 index 0000000..241890b --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift @@ -0,0 +1,98 @@ +// +// OutboundAPI.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import Alamofire + +class OutboundAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 출고 목록 조회 + func getOutboundList() async throws -> [OutboundDto] { + let response = try await networkManager.request( + endpoint: "agency/1/outbound", + method: .get, + responseType: [OutboundDto].self + ) + print("OutboundAPI - getOutboundList response: \(response)") + return response.data ?? [] + } + + // 출고 목록에 부품 추가 + func addOutbound(request: AddOutboundRequestDto) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/outbound", + method: .post, + parameters: request.toDictionary(), + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 처리 + func processOutbound() async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/process", + method: .post, + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 항목 삭제 + func deleteOutbound(outboundId: Int) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/\(outboundId)", + method: .delete, + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 수량 변경 + func updateOutbound(outboundId: Int, request: UpdateOutboundRequestDto) async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/\(outboundId)", + method: .patch, + parameters: request.toDictionary(), + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 목록 전체 비우기 + func deleteAllOutbound() async throws -> Void { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/clear", + method: .delete, + responseType: APIResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } +} + +// Helper extension to convert DTOs to dictionary +extension Encodable { + func toDictionary() -> [String: Any]? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} diff --git a/SampoomManagement/Features/Outbound/Data/Remote/DTO/AddOutboundRequestDto.swift b/SampoomManagement/Features/Outbound/Data/Remote/DTO/AddOutboundRequestDto.swift new file mode 100644 index 0000000..4995f08 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/DTO/AddOutboundRequestDto.swift @@ -0,0 +1,13 @@ +// +// AddOutboundRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct AddOutboundRequestDto: Codable { + let partId: Int + let quantity: Int +} diff --git a/SampoomManagement/Features/Outbound/Data/Remote/DTO/OutboundDto.swift b/SampoomManagement/Features/Outbound/Data/Remote/DTO/OutboundDto.swift new file mode 100644 index 0000000..086e589 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/DTO/OutboundDto.swift @@ -0,0 +1,28 @@ +// +// OutboundDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct OutboundDto: Codable { + let categoryId: Int + let categoryName: String + let groups: [OutboundGroupDto] +} + +struct OutboundGroupDto: Codable { + let groupId: Int + let groupName: String + let parts: [OutboundPartDto] +} + +struct OutboundPartDto: Codable { + let outboundId: Int + let partId: Int + let code: String + let name: String + let quantity: Int +} diff --git a/SampoomManagement/Features/Outbound/Data/Remote/DTO/UpdateOutboundRequestDto.swift b/SampoomManagement/Features/Outbound/Data/Remote/DTO/UpdateOutboundRequestDto.swift new file mode 100644 index 0000000..52262f3 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/DTO/UpdateOutboundRequestDto.swift @@ -0,0 +1,12 @@ +// +// UpdateOutboundRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct UpdateOutboundRequestDto: Codable { + let quantity: Int +} diff --git a/SampoomManagement/Features/Outbound/Data/Repository/OutboundRepositoryImpl.swift b/SampoomManagement/Features/Outbound/Data/Repository/OutboundRepositoryImpl.swift new file mode 100644 index 0000000..02299fd --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Repository/OutboundRepositoryImpl.swift @@ -0,0 +1,44 @@ +// +// OutboundRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class OutboundRepositoryImpl: OutboundRepository { + private let api: OutboundAPI + + init(api: OutboundAPI) { + self.api = api + } + + func getOutboundList() async throws -> OutboundList { + let data: [OutboundDto] = try await api.getOutboundList() + let outboundItems = data.map { $0.toModel() } + return OutboundList(items: outboundItems) + } + + func processOutbound() async throws { + try await api.processOutbound() + } + + func addOutbound(partId: Int, quantity: Int) async throws { + let request = AddOutboundRequestDto(partId: partId, quantity: quantity) + try await api.addOutbound(request: request) + } + + func deleteOutbound(outboundId: Int) async throws { + try await api.deleteOutbound(outboundId: outboundId) + } + + func deleteAllOutbound() async throws { + try await api.deleteAllOutbound() + } + + func updateOutboundQuantity(outboundId: Int, quantity: Int) async throws { + let request = UpdateOutboundRequestDto(quantity: quantity) + try await api.updateOutbound(outboundId: outboundId, request: request) + } +} \ No newline at end of file diff --git a/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift b/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift new file mode 100644 index 0000000..09fa0b0 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift @@ -0,0 +1,28 @@ +// +// Outbound.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct Outbound: Equatable { + let categoryId: Int + let categoryName: String + let groups: [OutboundGroup] +} + +struct OutboundGroup: Equatable { + let groupId: Int + let groupName: String + let parts: [OutboundPart] +} + +struct OutboundPart: Equatable { + let outboundId: Int + let partId: Int + let code: String + let name: String + let quantity: Int +} diff --git a/SampoomManagement/Features/Outbound/Domain/Models/OutboundList.swift b/SampoomManagement/Features/Outbound/Domain/Models/OutboundList.swift new file mode 100644 index 0000000..d4209ff --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/Models/OutboundList.swift @@ -0,0 +1,24 @@ +// +// OutboundList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct OutboundList: Equatable { + let items: [Outbound] + let totalCount: Int + let isEmpty: Bool + + init(items: [Outbound]) { + self.items = items + self.totalCount = items.count + self.isEmpty = items.isEmpty + } + + static func empty() -> OutboundList { + return OutboundList(items: []) + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/Repository/OutboundRepository.swift b/SampoomManagement/Features/Outbound/Domain/Repository/OutboundRepository.swift new file mode 100644 index 0000000..98dcb14 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/Repository/OutboundRepository.swift @@ -0,0 +1,17 @@ +// +// OutboundRepository.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +protocol OutboundRepository { + func getOutboundList() async throws -> OutboundList + func processOutbound() async throws + func addOutbound(partId: Int, quantity: Int) async throws + func deleteOutbound(outboundId: Int) async throws + func deleteAllOutbound() async throws + func updateOutboundQuantity(outboundId: Int, quantity: Int) async throws +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/AddOutboundUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/AddOutboundUseCase.swift new file mode 100644 index 0000000..357e1af --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/AddOutboundUseCase.swift @@ -0,0 +1,20 @@ +// +// AddOutboundUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class AddOutboundUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute(partId: Int, quantity: Int) async throws { + return try await repository.addOutbound(partId: partId, quantity: quantity) + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteAllOutboundUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteAllOutboundUseCase.swift new file mode 100644 index 0000000..abf8d05 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteAllOutboundUseCase.swift @@ -0,0 +1,20 @@ +// +// DeleteAllOutboundUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class DeleteAllOutboundUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute() async throws { + return try await repository.deleteAllOutbound() + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteOutboundUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteOutboundUseCase.swift new file mode 100644 index 0000000..e6b6ff2 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/DeleteOutboundUseCase.swift @@ -0,0 +1,20 @@ +// +// DeleteOutboundUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class DeleteOutboundUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute(outboundId: Int) async throws { + return try await repository.deleteOutbound(outboundId: outboundId) + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/GetOutboundUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/GetOutboundUseCase.swift new file mode 100644 index 0000000..e9c6ed8 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/GetOutboundUseCase.swift @@ -0,0 +1,20 @@ +// +// GetOutboundUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class GetOutboundUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute() async throws -> OutboundList { + return try await repository.getOutboundList() + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/ProcessOutboundUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/ProcessOutboundUseCase.swift new file mode 100644 index 0000000..2a34b85 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/ProcessOutboundUseCase.swift @@ -0,0 +1,20 @@ +// +// ProcessOutboundUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class ProcessOutboundUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute() async throws { + return try await repository.processOutbound() + } +} diff --git a/SampoomManagement/Features/Outbound/Domain/UseCase/UpdateOutboundQuantityUseCase.swift b/SampoomManagement/Features/Outbound/Domain/UseCase/UpdateOutboundQuantityUseCase.swift new file mode 100644 index 0000000..e9abf96 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/UseCase/UpdateOutboundQuantityUseCase.swift @@ -0,0 +1,20 @@ +// +// UpdateOutboundQuantityUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class UpdateOutboundQuantityUseCase { + private let repository: OutboundRepository + + init(repository: OutboundRepository) { + self.repository = repository + } + + func execute(outboundId: Int, quantity: Int) async throws { + return try await repository.updateOutboundQuantity(outboundId: outboundId, quantity: quantity) + } +} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListUiEvent.swift b/SampoomManagement/Features/Outbound/UI/OutboundListUiEvent.swift new file mode 100644 index 0000000..b696fd0 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListUiEvent.swift @@ -0,0 +1,19 @@ +// +// OutboundListUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum OutboundListUiEvent { + case loadOutboundList + case retryOutboundList + case processOutbound + case updateQuantity(outboundId: Int, quantity: Int) + case deleteOutbound(outboundId: Int) + case deleteAllOutbound + case clearUpdateError + case clearDeleteError +} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift b/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift new file mode 100644 index 0000000..1c50ed2 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift @@ -0,0 +1,66 @@ +// +// OutboundListUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct OutboundListUiState { + let outboundList: [Outbound] + let outboundLoading: Bool + let outboundError: String? + let selectedOutbound: Outbound? + let isUpdating: Bool + let updateError: String? + let isDeleting: Bool + let deleteError: String? + let isOrderSuccess: Bool + + init( + outboundList: [Outbound] = [], + outboundLoading: Bool = false, + outboundError: String? = nil, + selectedOutbound: Outbound? = nil, + isUpdating: Bool = false, + updateError: String? = nil, + isDeleting: Bool = false, + deleteError: String? = nil, + isOrderSuccess: Bool = false + ) { + self.outboundList = outboundList + self.outboundLoading = outboundLoading + self.outboundError = outboundError + self.selectedOutbound = selectedOutbound + self.isUpdating = isUpdating + self.updateError = updateError + self.isDeleting = isDeleting + self.deleteError = deleteError + self.isOrderSuccess = isOrderSuccess + } + + func copy( + outboundList: [Outbound]? = nil, + outboundLoading: Bool? = nil, + outboundError: String? = nil, + selectedOutbound: Outbound? = nil, + isUpdating: Bool? = nil, + updateError: String? = nil, + isDeleting: Bool? = nil, + deleteError: String? = nil, + isOrderSuccess: Bool? = nil + ) -> OutboundListUiState { + return OutboundListUiState( + outboundList: outboundList ?? self.outboundList, + outboundLoading: outboundLoading ?? self.outboundLoading, + outboundError: outboundError ?? self.outboundError, + selectedOutbound: selectedOutbound ?? self.selectedOutbound, + isUpdating: isUpdating ?? self.isUpdating, + updateError: updateError ?? self.updateError, + isDeleting: isDeleting ?? self.isDeleting, + deleteError: deleteError ?? self.deleteError, + isOrderSuccess: isOrderSuccess ?? self.isOrderSuccess + ) + } +} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift new file mode 100644 index 0000000..f85d836 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -0,0 +1,268 @@ +// +// OutboundListView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import SwiftUI +import Toast + +struct OutboundListView: View { + @ObservedObject var viewModel: OutboundListViewModel + @State private var showEmptyOutboundDialog = false + @State private var showConfirmDialog = false + + init(viewModel: OutboundListViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + + // 메인 콘텐츠 + mainContentSection + } + .navigationTitle("출고") + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !viewModel.uiState.outboundList.isEmpty && + !viewModel.uiState.outboundLoading && + viewModel.uiState.outboundError == nil { + Button("출고목록 비우기") { + showEmptyOutboundDialog = true + } + .foregroundColor(.red) + } + } + } + .background(Color.background) + .onAppear { + viewModel.clearSuccess() + viewModel.bindLabel(error: "오류가 발생했습니다") + viewModel.onEvent(.loadOutboundList) + } + .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in + if newValue { + Toast.text("출고 주문 성공").show() + viewModel.clearSuccess() + } + } + .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in + if let error = newValue { + Toast.text("수량 업데이트 에러: \(error)").show() + viewModel.onEvent(.clearUpdateError) + } + } + .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in + if let error = newValue { + Toast.text("삭제 에러: \(error)").show() + viewModel.onEvent(.clearDeleteError) + } + } + .alert("전체 삭제", isPresented: $showEmptyOutboundDialog) { + Button("취소", role: .cancel) { } + Button("확인") { + viewModel.onEvent(.deleteAllOutbound) + } + } message: { + Text("모든 출고 항목을 삭제하시겠습니까?") + } + .alert("출고 주문", isPresented: $showConfirmDialog) { + Button("취소", role: .cancel) { } + Button("확인") { + viewModel.onEvent(.processOutbound) + } + } message: { + Text("선택한 항목들을 출고 주문하시겠습니까?") + } + } + + @ViewBuilder + private var mainContentSection: some View { + if viewModel.uiState.outboundLoading { + // 로딩 상태 + HStack { + Spacer() + ProgressView() + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.uiState.outboundError { + // 에러 상태 + HStack { + Spacer() + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryOutboundList) } + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.uiState.outboundList.isEmpty { + // 빈 상태 + HStack { + Spacer() + EmptyView( + icon: "tray", + title: "출고 항목이 없습니다" + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // 출고 리스트 + ZStack { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.uiState.outboundList, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in + OutboundSection( + categoryName: category.categoryName, + groupName: group.groupName, + parts: group.parts, + isUpdating: viewModel.uiState.isUpdating, + isDeleting: viewModel.uiState.isDeleting, + onEvent: viewModel.onEvent + ) + } + } + + Spacer() + .frame(height: 100) + } + .padding(.horizontal, 16) + } + + // 출고 주문 버튼 + VStack { + Spacer() + CommonButton("부품 출고처리", backgroundColor: .red, textColor: .white) { + showConfirmDialog = true + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + } + } +} + +struct OutboundSection: View { + let categoryName: String + let groupName: String + let parts: [OutboundPart] + let isUpdating: Bool + let isDeleting: Bool + let onEvent: (OutboundListUiEvent) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(categoryName) > \(groupName)") + .font(.gmarketTitle3) + .foregroundColor(.text) + + ForEach(parts, id: \.outboundId) { part in + OutboundPartItem( + part: part, + isUpdating: isUpdating, + isDeleting: isDeleting, + onEvent: onEvent + ) + } + } + } +} + +struct OutboundPartItem: View { + let part: OutboundPart + let isUpdating: Bool + let isDeleting: Bool + let onEvent: (OutboundListUiEvent) -> Void + + var body: some View { + VStack(spacing: 0) { + // 부품 정보 + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + CommonButton("", icon: "trash", backgroundColor: .clear, textColor: .red) { + onEvent(.deleteOutbound(outboundId: part.outboundId)) + } + .frame(width: 44, height: 44) + .disabled(isDeleting) + } + .padding(16) + + // 수량 조절 + HStack { + Text("수량") + .font(.gmarketBody) + .foregroundColor(.text) + + Spacer() + + HStack(spacing: 8) { + CommonButton("-", backgroundColor: .disable, textColor: .text) { + if part.quantity > 1 { + onEvent(.updateQuantity(outboundId: part.outboundId, quantity: part.quantity - 1)) + } + } + .frame(width: 50, height: 44) + .disabled(isUpdating || part.quantity <= 1) + + Text("\(part.quantity)") + .font(.gmarketTitle3) + .foregroundColor(.text) + .frame(width: 100) + .multilineTextAlignment(.center) + + CommonButton("+", backgroundColor: .disable, textColor: .text) { + onEvent(.updateQuantity(outboundId: part.outboundId, quantity: part.quantity + 1)) + } + .frame(width: 50, height: 44) + .disabled(isUpdating) + } + } + .padding(16) + } + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.backgroundCard) + ) + } +} + +#Preview { + OutboundListView(viewModel: OutboundListViewModel( + getOutboundUseCase: GetOutboundUseCase(repository: MockOutboundRepository()), + processOutboundUseCase: ProcessOutboundUseCase(repository: MockOutboundRepository()), + updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase(repository: MockOutboundRepository()), + deleteOutboundUseCase: DeleteOutboundUseCase(repository: MockOutboundRepository()), + deleteAllOutboundUseCase: DeleteAllOutboundUseCase(repository: MockOutboundRepository()) + )) +} + +// Preview용 Mock Repository +class MockOutboundRepository: OutboundRepository { + func getOutboundList() async throws -> OutboundList { + return OutboundList.empty() + } + + func processOutbound() async throws {} + func addOutbound(partId: Int, quantity: Int) async throws {} + func deleteOutbound(outboundId: Int) async throws {} + func deleteAllOutbound() async throws {} + func updateOutboundQuantity(outboundId: Int, quantity: Int) async throws {} +} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift new file mode 100644 index 0000000..f6f5cdc --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift @@ -0,0 +1,229 @@ +// +// OutboundListViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class OutboundListViewModel: ObservableObject { + @Published var uiState = OutboundListUiState() + + private let getOutboundUseCase: GetOutboundUseCase + private let processOutboundUseCase: ProcessOutboundUseCase + private let updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase + private let deleteOutboundUseCase: DeleteOutboundUseCase + private let deleteAllOutboundUseCase: DeleteAllOutboundUseCase + + private var errorLabel: String = "" + + init( + getOutboundUseCase: GetOutboundUseCase, + processOutboundUseCase: ProcessOutboundUseCase, + updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase, + deleteOutboundUseCase: DeleteOutboundUseCase, + deleteAllOutboundUseCase: DeleteAllOutboundUseCase + ) { + self.getOutboundUseCase = getOutboundUseCase + self.processOutboundUseCase = processOutboundUseCase + self.updateOutboundQuantityUseCase = updateOutboundQuantityUseCase + self.deleteOutboundUseCase = deleteOutboundUseCase + self.deleteAllOutboundUseCase = deleteAllOutboundUseCase + } + + func bindLabel(error: String) { + errorLabel = error + } + + func onEvent(_ event: OutboundListUiEvent) { + switch event { + case .loadOutboundList: + loadOutboundList() + case .retryOutboundList: + loadOutboundList() + case .processOutbound: + processOutbound() + case .updateQuantity(let outboundId, let quantity): + updateQuantity(outboundId: outboundId, quantity: quantity) + case .deleteOutbound(let outboundId): + deleteOutbound(outboundId: outboundId) + case .deleteAllOutbound: + deleteAllOutbound() + case .clearUpdateError: + uiState = uiState.copy(updateError: nil) + case .clearDeleteError: + uiState = uiState.copy(deleteError: nil) + } + } + + private func loadOutboundList() { + Task { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + + do { + let outboundList = try await getOutboundUseCase.execute() + uiState = uiState.copy( + outboundList: outboundList.items, + outboundLoading: false, + outboundError: nil + ) + } catch { + uiState = uiState.copy( + outboundLoading: false, + outboundError: error.localizedDescription + ) + } + print("OutboundListViewModel - loadOutboundList: \(uiState)") + } + } + + private func processOutbound() { + Task { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + + do { + try await processOutboundUseCase.execute() + uiState = uiState.copy(outboundLoading: false, isOrderSuccess: true) + loadOutboundList() // 성공 후 리스트 새로고침 + } catch { + uiState = uiState.copy( + outboundLoading: false, + outboundError: error.localizedDescription + ) + } + print("OutboundListViewModel - processOutbound: \(uiState)") + } + } + + private func updateQuantity(outboundId: Int, quantity: Int) { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + updateLocalQuantity(outboundId: outboundId, quantity: quantity) + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isUpdating: true, updateError: nil) + + do { + try await updateOutboundQuantityUseCase.execute(outboundId: outboundId, quantity: quantity) + uiState = uiState.copy(isUpdating: false) + print("OutboundListViewModel - updateQuantity success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + print("OutboundListViewModel - updateQuantity error: \(error)") + } + } + } + + private func updateLocalQuantity(outboundId: Int, quantity: Int) { + let updatedList = uiState.outboundList.map { category in + Outbound( + categoryId: category.categoryId, + categoryName: category.categoryName, + groups: category.groups.map { group in + OutboundGroup( + groupId: group.groupId, + groupName: group.groupName, + parts: group.parts.map { part in + if part.outboundId == outboundId { + OutboundPart( + outboundId: part.outboundId, + partId: part.partId, + code: part.code, + name: part.name, + quantity: quantity + ) + } else { + part + } + } + ) + } + ) + } + uiState = uiState.copy(outboundList: updatedList) + } + + private func deleteOutbound(outboundId: Int) { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + removeFromLocalList(outboundId: outboundId) + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + + do { + try await deleteOutboundUseCase.execute(outboundId: outboundId) + uiState = uiState.copy(isDeleting: false) + print("OutboundListViewModel - deleteOutbound success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + print("OutboundListViewModel - deleteOutbound error: \(error)") + } + } + } + + private func removeFromLocalList(outboundId: Int) { + let updatedList = uiState.outboundList.compactMap { category in + let updatedGroups = category.groups.compactMap { group in + let filteredParts = group.parts.filter { $0.outboundId != outboundId } + return filteredParts.isEmpty ? nil : OutboundGroup( + groupId: group.groupId, + groupName: group.groupName, + parts: filteredParts + ) + } + return updatedGroups.isEmpty ? nil : Outbound( + categoryId: category.categoryId, + categoryName: category.categoryName, + groups: updatedGroups + ) + } + uiState = uiState.copy(outboundList: updatedList) + } + + private func deleteAllOutbound() { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + removeAllFromLocalList() + + // 2. 백그라운드에서 서버 동기화 + Task { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + + do { + try await deleteAllOutboundUseCase.execute() + uiState = uiState.copy(isDeleting: false) + print("OutboundListViewModel - deleteAllOutbound success: \(uiState)") + } catch { + // 3. 실패 시 원래 상태로 롤백하고 에러 표시 + loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + print("OutboundListViewModel - deleteAllOutbound error: \(error)") + } + } + } + + private func removeAllFromLocalList() { + uiState = uiState.copy(outboundList: []) + } + + func clearSuccess() { + uiState = uiState.copy(isOrderSuccess: false) + } +} diff --git a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift index 03a434c..d675cfc 100644 --- a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift +++ b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift @@ -15,56 +15,29 @@ class PartAPI { } func getCategoryList() async throws -> CategoryList { - return try await withCheckedThrowingContinuation { continuation in - networkManager.request( - endpoint: "agency/category", - responseType: [CategoryDTO].self - ) { result in - switch result { - case .success(let response): - let categories = response.data.map { $0.toModel() } - let categoryList = CategoryList(items: categories) - continuation.resume(returning: categoryList) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + let response = try await networkManager.request( + endpoint: "agency/category", + responseType: [CategoryDTO].self + ) + let categories = (response.data ?? []).map { $0.toModel() } + return CategoryList(items: categories) } func getGroupList(categoryId: Int) async throws -> PartsGroupList { - return try await withCheckedThrowingContinuation { continuation in - networkManager.request( - endpoint: "agency/category/\(categoryId)", - responseType: [GroupDTO].self - ) { result in - switch result { - case .success(let response): - let groups = response.data.map { $0.toModel() } - let groupList = PartsGroupList(items: groups) - continuation.resume(returning: groupList) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + let response = try await networkManager.request( + endpoint: "agency/category/\(categoryId)", + responseType: [GroupDTO].self + ) + let groups = (response.data ?? []).map { $0.toModel() } + return PartsGroupList(items: groups) } func getPartList(groupId: Int) async throws -> PartList { - return try await withCheckedThrowingContinuation { continuation in - networkManager.request( - endpoint: "agency/1/group/\(groupId)", - responseType: [PartDTO].self - ) { result in - switch result { - case .success(let response): - let parts = response.data.map { $0.toModel() } - let partList = PartList(items: parts) - continuation.resume(returning: partList) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + let response = try await networkManager.request( + endpoint: "agency/1/group/\(groupId)", + responseType: [PartDTO].self + ) + let parts = (response.data ?? []).map { $0.toModel() } + return PartList(items: parts) } } diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift new file mode 100644 index 0000000..b8a4bca --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -0,0 +1,212 @@ +import SwiftUI +import Toast + +struct PartDetailBottomSheetView: View { + @ObservedObject var viewModel: PartDetailViewModel + @State private var showOutboundDialog = false + @State private var showCartDialog = false + @State private var quantityText: String = "1" + + private func decreaseQuantity() { viewModel.onEvent(.decreaseQuantity) } + private func increaseQuantity() { viewModel.onEvent(.increaseQuantity) } + private func addToOutbound() { + let id = viewModel.uiState.part?.id ?? 0 + let qty = viewModel.uiState.quantity + viewModel.onEvent(.addToOutbound(partId: id, quantity: qty)) + } + private func addToCart() { + let id = viewModel.uiState.part?.id ?? 0 + let qty = viewModel.uiState.quantity + viewModel.onEvent(.addToCart(partId: id, quantity: qty)) + } + + private var partName: String { viewModel.uiState.part?.name ?? "N/A" } + private var partCode: String { viewModel.uiState.part?.code ?? "N/A" } + private var quantityLabelText: String { "현재 수량: \(viewModel.uiState.part?.quantity ?? 0)EA" } + + var body: some View { + mainContent + .onAppear { + quantityText = String(viewModel.uiState.quantity) + viewModel.clearSuccess() + } + .onChange(of: viewModel.uiState.quantity) { _, newValue in + handleQuantityChange(newValue) + } + .onChange(of: quantityText) { _, newText in + handleQuantityTextChange(newText) + } + .onChange(of: viewModel.uiState.isOutboundSuccess) { _, newValue in + handleOutboundSuccess(newValue) + } + .onChange(of: viewModel.uiState.isCartSuccess) { _, newValue in + handleCartSuccess(newValue) + } + .onChange(of: viewModel.uiState.updateError) { _, newValue in + handleUpdateError(newValue) + } + .alert("출고 확인", isPresented: $showOutboundDialog) { + Button("확인") { addToOutbound() } + Button("취소", role: .cancel) { } + } message: { + Text("선택하신 부품을 출고 목록에 추가하시겠습니까?") + } + .alert("장바구니 확인", isPresented: $showCartDialog) { + Button("확인") { addToCart() } + Button("취소", role: .cancel) { } + } message: { + Text("선택하신 부품을 장바구니에 추가하시겠습니까?") + } + } + + private var mainContent: some View { + NavigationView { + VStack(alignment: .leading, spacing: 16) { + PartInfoHeaderView( + name: partName, + code: partCode, + quantityLabel: quantityLabelText + ) + + QuantityControlView( + quantityText: $quantityText, + decreaseAction: decreaseQuantity, + increaseAction: increaseQuantity, + isDecreaseDisabled: viewModel.uiState.quantity <= 1 || viewModel.uiState.isUpdating, + isIncreaseDisabled: viewModel.uiState.isUpdating + ) + + Spacer() + + ActionButtonsView( + addOutboundAction: { showOutboundDialog = true }, + addCartAction: { showCartDialog = true }, + isDisabled: viewModel.uiState.isUpdating + ) + } + .padding(24) + .background(Color.background) +// .navigationTitle("부품 상세") + .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem(placement: .topBarTrailing) { +// Button("닫기") { viewModel.onEvent(.dismiss) } +// } +// } + } + } + + private func handleQuantityChange(_ newValue: Int) { + if quantityText != String(newValue) { + quantityText = String(newValue) + } + } + + private func handleQuantityTextChange(_ newText: String) { + if let q = Int(newText), q > 0 { + viewModel.onEvent(.setQuantity(q)) + } + } + + private func handleOutboundSuccess(_ newValue: Bool) { + if newValue { + Toast.text("출고 성공!").show() + showOutboundDialog = false + viewModel.clearSuccess() + } + } + + private func handleCartSuccess(_ newValue: Bool) { + if newValue { + Toast.text("장바구니 추가 성공!").show() + showCartDialog = false + viewModel.clearSuccess() + } + } + + private func handleUpdateError(_ newValue: String?) { + if let error = newValue { + Toast.text("에러 발생: \(error)").show() + viewModel.onEvent(.clearError) + } + } +} + +private struct PartInfoHeaderView: View { + let name: String + let code: String + let quantityLabel: String + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(name) + .font(.gmarketTitle2) + .foregroundColor(.text) + Text(code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + Spacer() + Text(quantityLabel) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + } +} + +private struct QuantityControlView: View { + @Binding var quantityText: String + let decreaseAction: () -> Void + let increaseAction: () -> Void + let isDecreaseDisabled: Bool + let isIncreaseDisabled: Bool + + var body: some View { + HStack { + Text("수량") + .font(.gmarketBody) + .foregroundColor(.text) + Spacer() + HStack { + CommonButton("-", backgroundColor: .disable, textColor: .text) { + decreaseAction() + } + .frame(width: 50, height: 44) + .disabled(isDecreaseDisabled) + + TextField("수량", text: $quantityText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .frame(width: 100) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + + CommonButton("+", backgroundColor: .disable, textColor: .text) { + increaseAction() + } + .frame(width: 50, height: 44) + .disabled(isIncreaseDisabled) + } + } + } +} + +private struct ActionButtonsView: View { + let addOutboundAction: () -> Void + let addCartAction: () -> Void + let isDisabled: Bool + + var body: some View { + HStack { + CommonButton("출고 추가", customIcon: "outbound", backgroundColor: .red, textColor: .white) { + addOutboundAction() + } + .disabled(isDisabled) + + CommonButton("장바구니 추가", customIcon: "cart", backgroundColor: .blue, textColor: .white) { + addCartAction() + } + .disabled(isDisabled) + } + } +} diff --git a/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift b/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift new file mode 100644 index 0000000..e0b4a6a --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift @@ -0,0 +1,19 @@ +// +// PartDetailUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum PartDetailUiEvent { + case initialize(Part) + case increaseQuantity + case decreaseQuantity + 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 new file mode 100644 index 0000000..a3b4b6c --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift @@ -0,0 +1,51 @@ +// +// PartDetailUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartDetailUiState { + let part: Part? + let quantity: Int + let isUpdating: Bool + let updateError: String? + let isOutboundSuccess: Bool + let isCartSuccess: Bool + + init( + 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 + } + + func copy( + part: Part? = nil, + quantity: Int? = nil, + isUpdating: Bool? = nil, + updateError: String? = nil, + isOutboundSuccess: Bool? = nil, + isCartSuccess: Bool? = nil + ) -> PartDetailUiState { + return 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 new file mode 100644 index 0000000..36bd8c4 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift @@ -0,0 +1,113 @@ +// +// PartDetailViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class PartDetailViewModel: ObservableObject { + @Published var uiState = PartDetailUiState() + + private let addOutboundUseCase: AddOutboundUseCase + private let addCartUseCase: AddCartUseCase + + private var errorLabel: String = "" + + init(addOutboundUseCase: AddOutboundUseCase, addCartUseCase: AddCartUseCase) { + self.addOutboundUseCase = addOutboundUseCase + self.addCartUseCase = addCartUseCase + } + + func bindLabel(error: String) { + errorLabel = error + } + + func onEvent(_ event: PartDetailUiEvent) { + switch event { + case .initialize(let part): + uiState = uiState.copy( + part: part, + quantity: 1, + isUpdating: false, + updateError: nil, + isOutboundSuccess: false, + isCartSuccess: false + ) + case .increaseQuantity: + let currentQuantity = uiState.quantity + uiState = uiState.copy(quantity: currentQuantity + 1) + case .decreaseQuantity: + let currentQuantity = uiState.quantity + uiState = uiState.copy(quantity: max(1, currentQuantity - 1)) + case .setQuantity(let quantity): + if quantity > 0 { + uiState = uiState.copy(quantity: quantity) + } + case .addToOutbound(let partId, let quantity): + let part = uiState.part + if part != nil { + addToOutbound(partId: partId, quantity: quantity) + } + case .addToCart(let partId, let quantity): + let part = uiState.part + if part != nil { + addToCart(partId: partId, quantity: quantity) + } + case .clearError: + uiState = uiState.copy(updateError: nil) + case .dismiss: + uiState = uiState.copy( + part: nil, + quantity: 1, + updateError: nil + ) + } + } + + private func addToOutbound(partId: Int, quantity: Int) { + Task { + uiState = uiState.copy(isUpdating: true, updateError: nil) + + do { + try await addOutboundUseCase.execute(partId: partId, quantity: quantity) + + uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) + print("PartDetailViewModel - addToOutbound success: \(uiState)") + } catch { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + print("PartDetailViewModel - addToOutbound error: \(error)") + } + } + } + + private func addToCart(partId: Int, quantity: Int) { + Task { + uiState = uiState.copy(isUpdating: true, updateError: nil) + + do { + try await addCartUseCase.execute(partId: partId, quantity: quantity) + + uiState = uiState.copy(isUpdating: false, isCartSuccess: true) + print("PartDetailViewModel - addToCart success: \(uiState)") + } catch { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + print("PartDetailViewModel - addToCart error: \(error)") + } + } + } + + func clearSuccess() { + uiState = uiState.copy(isOutboundSuccess: false, isCartSuccess: false) + } +} diff --git a/SampoomManagement/Features/Part/UI/PartListUiEvent.swift b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift index f99c216..f286e8f 100644 --- a/SampoomManagement/Features/Part/UI/PartListUiEvent.swift +++ b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift @@ -10,4 +10,6 @@ import Foundation enum PartListUiEvent { case loadPartList case retryPartList + case showBottomSheet(Part) + case dismissBottomSheet } diff --git a/SampoomManagement/Features/Part/UI/PartListUiState.swift b/SampoomManagement/Features/Part/UI/PartListUiState.swift index e9bc38e..7a2d40b 100644 --- a/SampoomManagement/Features/Part/UI/PartListUiState.swift +++ b/SampoomManagement/Features/Part/UI/PartListUiState.swift @@ -11,26 +11,31 @@ struct PartListUiState { let partList: [Part] let partListLoading: Bool let partListError: String? + let selectedPart: Part? init( partList: [Part] = [], partListLoading: Bool = false, - partListError: String? = nil + partListError: String? = nil, + selectedPart: Part? = nil ) { self.partList = partList self.partListLoading = partListLoading self.partListError = partListError + self.selectedPart = selectedPart } func copy( partList: [Part]? = nil, partListLoading: Bool? = nil, - partListError: String? = nil + partListError: String? = nil, + selectedPart: Part? = nil ) -> PartListUiState { return PartListUiState( partList: partList ?? self.partList, partListLoading: partListLoading ?? self.partListLoading, - partListError: partListError ?? self.partListError + partListError: partListError ?? self.partListError, + selectedPart: selectedPart ?? self.selectedPart ) } } diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift index f376226..1c0a572 100644 --- a/SampoomManagement/Features/Part/UI/PartListView.swift +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -9,20 +9,27 @@ import SwiftUI struct PartListView: View { @ObservedObject var viewModel: PartListViewModel + @State private var showBottomSheet = false + let dependencies: AppDependencies init( - viewModel: PartListViewModel + viewModel: PartListViewModel, + dependencies: AppDependencies ) { self.viewModel = viewModel + self.dependencies = dependencies } var body: some View { VStack(spacing: 0) { if viewModel.uiState.partListLoading { // 로딩 상태 - ProgressView() - .frame(width: .infinity, height: .infinity) - .background(Color.background) + HStack { + Spacer() + ProgressView() + Spacer() + } + .background(Color.background) } else if let error = viewModel.uiState.partListError { // 에러 상태 Spacer() @@ -46,7 +53,13 @@ struct PartListView: View { ScrollView { LazyVStack(spacing: 8) { ForEach(viewModel.uiState.partList, id: \.id) { part in - PartListItemCard(part: part) + PartListItemCard( + part: part, + onClick: { + viewModel.onEvent(.showBottomSheet(part)) + showBottomSheet = true + } + ) } } .padding(16) @@ -56,39 +69,60 @@ struct PartListView: View { .navigationTitle("부품조회") .navigationBarTitleDisplayMode(.automatic) .background(Color.background) + .sheet(isPresented: $showBottomSheet) { + if let selectedPart = viewModel.uiState.selectedPart { + let detailViewModel = dependencies.makePartDetailViewModel() + PartDetailBottomSheetView(viewModel: detailViewModel) + .onAppear { + detailViewModel.onEvent(.initialize(selectedPart)) + } + .onDisappear { + showBottomSheet = false + viewModel.onEvent(.dismissBottomSheet) + } + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.automatic) + .presentationBackground(.clear) + } + } } } struct PartListItemCard: View { let part: Part + let onClick: () -> Void var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(part.name) + Button(action: onClick) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + Text("\(part.quantity)") .font(.gmarketTitle3) .foregroundColor(.text) - Text(part.code) - .font(.gmarketCaption) + Image(systemName: "chevron.right") .foregroundColor(.textSecondary) } - - Spacer() - - Text("\(part.quantity)") - .font(.gmarketTitle3) - .foregroundColor(.text) - - Image(systemName: "chevron.right") - .foregroundColor(.secondary) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.backgroundCard) + ) } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(.backgroundCard) - ) + .buttonStyle(PlainButtonStyle()) } } + diff --git a/SampoomManagement/Features/Part/UI/PartListViewModel.swift b/SampoomManagement/Features/Part/UI/PartListViewModel.swift index f5f814b..1cbeda7 100644 --- a/SampoomManagement/Features/Part/UI/PartListViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartListViewModel.swift @@ -29,8 +29,14 @@ class PartListViewModel: ObservableObject { func onEvent(_ event: PartListUiEvent) { switch event { - case .loadPartList:loadPartList() - case .retryPartList:loadPartList() + case .loadPartList: + loadPartList() + case .retryPartList: + loadPartList() + case .showBottomSheet(let part): + uiState = uiState.copy(selectedPart: part) + case .dismissBottomSheet: + uiState = uiState.copy(selectedPart: nil) } } @@ -63,11 +69,7 @@ class PartListViewModel: ObservableObject { ) } } - #if DEBUG - await MainActor.run { - print("PartListViewModel - loadPartList: \(self.uiState)") - } - #endif + print("PartListViewModel - loadPartList: \(self.uiState)") } } } diff --git a/SampoomManagement/Features/Part/UI/PartView.swift b/SampoomManagement/Features/Part/UI/PartView.swift index 1551dd2..2e53d82 100644 --- a/SampoomManagement/Features/Part/UI/PartView.swift +++ b/SampoomManagement/Features/Part/UI/PartView.swift @@ -59,17 +59,25 @@ struct PartView: View { .frame(height: 200) } else if let error = viewModel.uiState.categoryError { // 에러 상태 - ErrorView( - error: error, - onRetry: { viewModel.onEvent(.retryCategories) } - ) + HStack { + Spacer() + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryCategories) } + ) + Spacer() + } .frame(height: 200) } else if viewModel.uiState.categoryList.isEmpty { // 빈 상태 - EmptyView( - icon: "tray", - title: "카테고리가 없습니다" - ) + HStack { + Spacer() + EmptyView( + icon: "tray", + title: "카테고리가 없습니다" + ) + Spacer() + } .frame(height: 200) } else { // 카테고리 그리드 diff --git a/SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json similarity index 77% rename from SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json rename to SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json index b5b9b10..81dfa04 100644 --- a/SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json +++ b/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "delivery.svg", + "filename" : "outbound.svg", "idiom" : "universal" } ], diff --git a/SampoomManagement/Resources/Assets.xcassets/delivery.imageset/delivery.svg b/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/outbound.svg similarity index 100% rename from SampoomManagement/Resources/Assets.xcassets/delivery.imageset/delivery.svg rename to SampoomManagement/Resources/Assets.xcassets/outbound.imageset/outbound.svg From b71c31c497dc1e19c980c58d7061ba30d4e17279 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Mon, 20 Oct 2025 10:31:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[REFAC]=20StringResource=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Resources/StringResources.swift | 50 +++++++++++++++++++ .../Features/Cart/UI/CartListView.swift | 32 ++++++------ .../Outbound/UI/OutboundListView.swift | 30 +++++------ .../Part/UI/PartDetailBottomSheetView.swift | 42 ++++++++-------- 4 files changed, 102 insertions(+), 52 deletions(-) diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 3c4e1a5..72bab97 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -71,6 +71,56 @@ struct StringResources { static let done = "완료" } + // MARK: - Outbound + struct Outbound { + static let title = "출고" + static let emptyAll = "출고목록 비우기" + static let processOrder = "부품 출고처리" + static let orderSuccess = "출고 주문 성공" + static let updateQuantityError = "수량 업데이트 에러" + static let deleteError = "삭제 에러" + static let confirmProcessTitle = "출고 확인" + static let confirmProcessMessage = "선택하신 부품들을 출고 처리하시겠습니까?" + static let confirmEmptyTitle = "전체 삭제" + static let confirmEmptyMessage = "출고 목록을 모두 삭제하시겠습니까?" + } + + // MARK: - Cart + struct Cart { + static let title = "장바구니" + static let emptyAll = "장바구니 비우기" + static let processOrder = "부품 주문" + static let orderSuccess = "주문 성공!" + static let updateQuantityError = "수량 업데이트 에러" + static let deleteError = "삭제 에러" + static let confirmProcessTitle = "주문 확인" + static let confirmProcessMessage = "선택하신 부품을 주문하시겠습니까?" + static let confirmEmptyTitle = "장바구니 비우기" + static let confirmEmptyMessage = "장바구니를 비우시겠습니까?" + static let emptyMessage = "장바구니가 비어있습니다" + } + + // MARK: - PartDetail + struct PartDetail { + static let title = "부품 상세" + static let currentQuantity = "현재 수량" + static let quantity = "수량" + static let addToOutbound = "출고 추가" + static let addToCart = "장바구니 추가" + static let outboundSuccess = "출고 성공!" + static let cartSuccess = "장바구니 추가 성공!" + static let errorOccurred = "에러 발생" + static let confirmOutboundTitle = "출고 확인" + static let confirmOutboundMessage = "선택하신 부품을 출고 목록에 추가하시겠습니까?" + static let confirmCartTitle = "장바구니 확인" + static let confirmCartMessage = "선택하신 부품을 장바구니에 추가하시겠습니까?" + } + + // MARK: - Part + struct Part { + static let quantity = "수량" + } + // MARK: - Auth struct Auth { // Login diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index dd3989c..1c4245c 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -37,7 +37,7 @@ struct CartListView: View { } else if viewModel.uiState.cartList.isEmpty { HStack { Spacer() - EmptyView(title: "장바구니가 비어있습니다") + EmptyView(title: StringResources.Cart.emptyMessage) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -70,7 +70,7 @@ struct CartListView: View { // 주문하기 버튼 VStack { Spacer() - CommonButton("부품 주문", backgroundColor: .accentColor, textColor: .white) { + CommonButton(StringResources.Cart.processOrder, backgroundColor: .accentColor, textColor: .white) { showConfirmDialog = true } .padding(.horizontal, 16) @@ -79,12 +79,12 @@ struct CartListView: View { } } } - .navigationTitle("장바구니") + .navigationTitle(StringResources.Cart.title) .navigationBarTitleDisplayMode(.automatic) .toolbar { ToolbarItem(placement: .topBarTrailing) { if !viewModel.uiState.cartLoading && viewModel.uiState.cartError == nil && !viewModel.uiState.cartList.isEmpty { - Button("장바구니 비우기") { + Button(StringResources.Cart.emptyAll) { showEmptyCartDialog = true } .foregroundColor(.red) @@ -92,40 +92,40 @@ struct CartListView: View { } } .background(Color.background) - .alert("장바구니 비우기", isPresented: $showEmptyCartDialog) { - Button("확인") { + .alert(StringResources.Cart.confirmEmptyTitle, isPresented: $showEmptyCartDialog) { + Button(StringResources.Common.ok) { viewModel.onEvent(.deleteAllCart) } - Button("취소", role: .cancel) { } + Button(StringResources.Common.cancel, role: .cancel) { } } message: { - Text("장바구니를 비우시겠습니까?") + Text(StringResources.Cart.confirmEmptyMessage) } - .alert("주문 확인", isPresented: $showConfirmDialog) { - Button("확인") { + .alert(StringResources.Cart.confirmProcessTitle, isPresented: $showConfirmDialog) { + Button(StringResources.Common.ok) { viewModel.onEvent(.processOrder) } - Button("취소", role: .cancel) { } + Button(StringResources.Common.cancel, role: .cancel) { } } message: { - Text("선택하신 부품을 주문하시겠습니까?") + Text(StringResources.Cart.confirmProcessMessage) } .onAppear { viewModel.onEvent(.loadCartList) } .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in if newValue { - Toast.text("주문 성공!").show() + Toast.text(StringResources.Cart.orderSuccess).show() viewModel.clearSuccess() } } .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in if let error = newValue { - Toast.text("수량 업데이트 에러: \(error)").show() + Toast.text("\(StringResources.Cart.updateQuantityError): \(error)").show() viewModel.onEvent(.clearUpdateError) } } .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in if let error = newValue { - Toast.text("삭제 에러: \(error)").show() + Toast.text("\(StringResources.Cart.deleteError): \(error)").show() viewModel.onEvent(.clearDeleteError) } } @@ -189,7 +189,7 @@ struct CartPartItem: View { // 수량 조절 HStack { - Text("수량") + Text(StringResources.Part.quantity) .font(.gmarketBody) .foregroundColor(.text) diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift index f85d836..b37d543 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -23,14 +23,14 @@ struct OutboundListView: View { // 메인 콘텐츠 mainContentSection } - .navigationTitle("출고") + .navigationTitle(StringResources.Outbound.title) .navigationBarTitleDisplayMode(.automatic) .toolbar { ToolbarItem(placement: .topBarTrailing) { if !viewModel.uiState.outboundList.isEmpty && !viewModel.uiState.outboundLoading && viewModel.uiState.outboundError == nil { - Button("출고목록 비우기") { + Button(StringResources.Outbound.emptyAll) { showEmptyOutboundDialog = true } .foregroundColor(.red) @@ -45,37 +45,37 @@ struct OutboundListView: View { } .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in if newValue { - Toast.text("출고 주문 성공").show() + Toast.text(StringResources.Outbound.orderSuccess).show() viewModel.clearSuccess() } } .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in if let error = newValue { - Toast.text("수량 업데이트 에러: \(error)").show() + Toast.text("\(StringResources.Outbound.updateQuantityError): \(error)").show() viewModel.onEvent(.clearUpdateError) } } .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in if let error = newValue { - Toast.text("삭제 에러: \(error)").show() + Toast.text("\(StringResources.Outbound.deleteError): \(error)").show() viewModel.onEvent(.clearDeleteError) } } - .alert("전체 삭제", isPresented: $showEmptyOutboundDialog) { - Button("취소", role: .cancel) { } - Button("확인") { + .alert(StringResources.Outbound.confirmEmptyTitle, isPresented: $showEmptyOutboundDialog) { + Button(StringResources.Common.cancel, role: .cancel) { } + Button(StringResources.Common.ok) { viewModel.onEvent(.deleteAllOutbound) } } message: { - Text("모든 출고 항목을 삭제하시겠습니까?") + Text(StringResources.Outbound.confirmEmptyMessage) } - .alert("출고 주문", isPresented: $showConfirmDialog) { - Button("취소", role: .cancel) { } - Button("확인") { + .alert(StringResources.Outbound.confirmProcessTitle, isPresented: $showConfirmDialog) { + Button(StringResources.Common.cancel, role: .cancel) { } + Button(StringResources.Common.ok) { viewModel.onEvent(.processOutbound) } } message: { - Text("선택한 항목들을 출고 주문하시겠습니까?") + Text(StringResources.Outbound.confirmProcessMessage) } } @@ -138,7 +138,7 @@ struct OutboundListView: View { // 출고 주문 버튼 VStack { Spacer() - CommonButton("부품 출고처리", backgroundColor: .red, textColor: .white) { + CommonButton(StringResources.Outbound.processOrder, backgroundColor: .red, textColor: .white) { showConfirmDialog = true } .padding(.horizontal, 16) @@ -207,7 +207,7 @@ struct OutboundPartItem: View { // 수량 조절 HStack { - Text("수량") + Text(StringResources.Part.quantity) .font(.gmarketBody) .foregroundColor(.text) diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index b8a4bca..51b5bff 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -22,7 +22,7 @@ struct PartDetailBottomSheetView: View { private var partName: String { viewModel.uiState.part?.name ?? "N/A" } private var partCode: String { viewModel.uiState.part?.code ?? "N/A" } - private var quantityLabelText: String { "현재 수량: \(viewModel.uiState.part?.quantity ?? 0)EA" } + private var quantityLabelText: String { "\(StringResources.PartDetail.currentQuantity): \(viewModel.uiState.part?.quantity ?? 0)EA" } var body: some View { mainContent @@ -45,17 +45,17 @@ struct PartDetailBottomSheetView: View { .onChange(of: viewModel.uiState.updateError) { _, newValue in handleUpdateError(newValue) } - .alert("출고 확인", isPresented: $showOutboundDialog) { - Button("확인") { addToOutbound() } - Button("취소", role: .cancel) { } + .alert(StringResources.PartDetail.confirmOutboundTitle, isPresented: $showOutboundDialog) { + Button(StringResources.Common.ok) { addToOutbound() } + Button(StringResources.Common.cancel, role: .cancel) { } } message: { - Text("선택하신 부품을 출고 목록에 추가하시겠습니까?") + Text(StringResources.PartDetail.confirmOutboundMessage) } - .alert("장바구니 확인", isPresented: $showCartDialog) { - Button("확인") { addToCart() } - Button("취소", role: .cancel) { } + .alert(StringResources.PartDetail.confirmCartTitle, isPresented: $showCartDialog) { + Button(StringResources.Common.ok) { addToCart() } + Button(StringResources.Common.cancel, role: .cancel) { } } message: { - Text("선택하신 부품을 장바구니에 추가하시겠습니까?") + Text(StringResources.PartDetail.confirmCartMessage) } } @@ -86,13 +86,13 @@ struct PartDetailBottomSheetView: View { } .padding(24) .background(Color.background) -// .navigationTitle("부품 상세") + .navigationTitle(StringResources.PartDetail.title) .navigationBarTitleDisplayMode(.inline) -// .toolbar { -// ToolbarItem(placement: .topBarTrailing) { -// Button("닫기") { viewModel.onEvent(.dismiss) } -// } -// } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(StringResources.Navigation.close) { viewModel.onEvent(.dismiss) } + } + } } } @@ -110,7 +110,7 @@ struct PartDetailBottomSheetView: View { private func handleOutboundSuccess(_ newValue: Bool) { if newValue { - Toast.text("출고 성공!").show() + Toast.text(StringResources.PartDetail.outboundSuccess).show() showOutboundDialog = false viewModel.clearSuccess() } @@ -118,7 +118,7 @@ struct PartDetailBottomSheetView: View { private func handleCartSuccess(_ newValue: Bool) { if newValue { - Toast.text("장바구니 추가 성공!").show() + Toast.text(StringResources.PartDetail.cartSuccess).show() showCartDialog = false viewModel.clearSuccess() } @@ -126,7 +126,7 @@ struct PartDetailBottomSheetView: View { private func handleUpdateError(_ newValue: String?) { if let error = newValue { - Toast.text("에러 발생: \(error)").show() + Toast.text("\(StringResources.PartDetail.errorOccurred): \(error)").show() viewModel.onEvent(.clearError) } } @@ -164,7 +164,7 @@ private struct QuantityControlView: View { var body: some View { HStack { - Text("수량") + Text(StringResources.PartDetail.quantity) .font(.gmarketBody) .foregroundColor(.text) Spacer() @@ -198,12 +198,12 @@ private struct ActionButtonsView: View { var body: some View { HStack { - CommonButton("출고 추가", customIcon: "outbound", backgroundColor: .red, textColor: .white) { + CommonButton(StringResources.PartDetail.addToOutbound, customIcon: "outbound", backgroundColor: .red, textColor: .white) { addOutboundAction() } .disabled(isDisabled) - CommonButton("장바구니 추가", customIcon: "cart", backgroundColor: .blue, textColor: .white) { + CommonButton(StringResources.PartDetail.addToCart, customIcon: "cart", backgroundColor: .blue, textColor: .white) { addCartAction() } .disabled(isDisabled) From 32179363358de94c8fd71622adb0d39b4c5a35af Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Mon, 20 Oct 2025 11:02:29 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=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 --- .../Core/Network/NetworkError.swift | 3 + .../Core/Network/NetworkManager.swift | 50 +++++---- .../Core/UI/Components/CommonButton.swift | 8 +- .../Cart/Data/Remote/API/CartAPI.swift | 22 ++-- .../Features/Cart/UI/CartListUiState.swift | 6 +- .../Features/Cart/UI/CartListView.swift | 11 +- .../Features/Cart/UI/CartListViewModel.swift | 84 +++++++++------ .../Data/Remote/API/OutboundAPI.swift | 26 ++--- .../Outbound/UI/OutboundListUiState.swift | 6 +- .../Outbound/UI/OutboundListViewModel.swift | 102 +++++++++++------- .../Part/UI/PartDetailBottomSheetView.swift | 8 +- .../Features/Part/UI/PartDetailUiState.swift | 4 +- .../Part/UI/PartDetailViewModel.swift | 48 +++++---- .../Features/Part/UI/PartListUiState.swift | 4 +- .../Features/Part/UI/PartListViewModel.swift | 6 +- 15 files changed, 234 insertions(+), 154 deletions(-) diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index 0dd0eaa..cc93fd8 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -13,6 +13,7 @@ enum NetworkError: Error, LocalizedError { case invalidURL case noData case serverError(Int) + case invalidParameters var errorDescription: String? { switch self { @@ -26,6 +27,8 @@ enum NetworkError: Error, LocalizedError { return "데이터가 없습니다" case .serverError(let code): return "서버 오류: \(code)" + case .invalidParameters: + return "잘못된 매개변수입니다" } } } diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index 94c04f0..a8998b4 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -23,30 +23,36 @@ class NetworkManager { ) async throws -> APIResponse { let url = baseURL + endpoint - return try await withCheckedThrowingContinuation { continuation in - AF.request( - url, - method: method, - parameters: parameters, - encoding: JSONEncoding.default - ) - .responseData { response in - switch response.result { - case .success(let data): - do { - print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") - let apiResponse = try JSONDecoder().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)) + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { continuation in + let dataRequest = AF.request( + url, + method: method, + parameters: parameters, + encoding: JSONEncoding.default + ) + + dataRequest.responseData { response in + 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)) + } + case .failure(let error): + print("NetworkManager - Network error: \(error)") + continuation.resume(throwing: NetworkError.networkError(error)) } - case .failure(let error): - print("NetworkManager - Network error: \(error)") - continuation.resume(throwing: NetworkError.networkError(error)) } } - } + }, onCancel: { + // Task cancellation handled by Alamofire automatically + }) } } diff --git a/SampoomManagement/Core/UI/Components/CommonButton.swift b/SampoomManagement/Core/UI/Components/CommonButton.swift index 843a71d..ed8aa63 100644 --- a/SampoomManagement/Core/UI/Components/CommonButton.swift +++ b/SampoomManagement/Core/UI/Components/CommonButton.swift @@ -82,7 +82,9 @@ struct CommonButton: View { if let customIcon = customIcon, iconPosition == .leading { Image(customIcon) .renderingMode(.template) - .font(size.font) + .resizable() + .scaledToFit() + .frame(width: size.height * 0.5, height: size.height * 0.5) } else if let icon = icon, iconPosition == .leading { Image(systemName: icon) .font(size.font) @@ -94,7 +96,9 @@ struct CommonButton: View { if let customIcon = customIcon, iconPosition == .trailing { Image(customIcon) .renderingMode(.template) - .font(size.font) + .resizable() + .scaledToFit() + .frame(width: size.height * 0.5, height: size.height * 0.5) } else if let icon = icon, iconPosition == .trailing { Image(systemName: icon) .font(size.font) diff --git a/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift index 01fe5b6..cb2ba5a 100644 --- a/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift +++ b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift @@ -27,12 +27,13 @@ class CartAPI { } // 장바구니에 부품 추가 - func addCart(request: AddCartRequestDto) async throws -> Void { + func addCart(request: AddCartRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } let response = try await networkManager.request( endpoint: "agency/1/cart", method: .post, - parameters: request.toDictionary(), - responseType: APIResponse.self + parameters: params, + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -40,11 +41,11 @@ class CartAPI { } // 장바구니 항목 삭제 - func deleteCart(cartItemId: Int) async throws -> Void { + func deleteCart(cartItemId: Int) async throws { let response = try await networkManager.request( endpoint: "agency/1/cart/\(cartItemId)", method: .delete, - responseType: APIResponse.self + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -52,12 +53,13 @@ class CartAPI { } // 장바구니 수량 변경 - func updateCart(cartItemId: Int, request: UpdateCartRequestDto) async throws -> Void { + func updateCart(cartItemId: Int, request: UpdateCartRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } let response = try await networkManager.request( endpoint: "agency/1/cart/\(cartItemId)", method: .put, - parameters: request.toDictionary(), - responseType: APIResponse.self + parameters: params, + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -65,11 +67,11 @@ class CartAPI { } // 장바구니 전체 비우기 - func deleteAllCart() async throws -> Void { + func deleteAllCart() async throws { let response = try await networkManager.request( endpoint: "agency/1/cart/clear", method: .delete, - responseType: APIResponse.self + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) diff --git a/SampoomManagement/Features/Cart/UI/CartListUiState.swift b/SampoomManagement/Features/Cart/UI/CartListUiState.swift index 3cf7dd0..9f10611 100644 --- a/SampoomManagement/Features/Cart/UI/CartListUiState.swift +++ b/SampoomManagement/Features/Cart/UI/CartListUiState.swift @@ -43,12 +43,12 @@ struct CartListUiState { func copy( cartList: [Cart]? = nil, cartLoading: Bool? = nil, - cartError: String? = nil, + cartError: String?? = nil, selectedCart: Cart? = nil, isUpdating: Bool? = nil, - updateError: String? = nil, + updateError: String?? = nil, isDeleting: Bool? = nil, - deleteError: String? = nil, + deleteError: String?? = nil, isOrderSuccess: Bool? = nil ) -> CartListUiState { return CartListUiState( diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index 1c4245c..bb0c293 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -18,10 +18,13 @@ struct CartListView: View { VStack(spacing: 0) { // Content if viewModel.uiState.cartLoading { - Spacer() - ProgressView() - .scaleEffect(1.5) - Spacer() + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.uiState.cartError { HStack { Spacer() diff --git a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift index bd89d26..dd4fbcc 100644 --- a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -52,29 +52,35 @@ class CartListViewModel: ObservableObject { case .deleteAllCart: deleteAllCart() case .clearUpdateError: - uiState = uiState.copy(updateError: nil) + uiState = uiState.copy(updateError: .some(nil)) case .clearDeleteError: - uiState = uiState.copy(deleteError: nil) + uiState = uiState.copy(deleteError: .some(nil)) } } private func loadCartList() { Task { - uiState = uiState.copy(cartLoading: true, cartError: nil) + await MainActor.run { + uiState = uiState.copy(cartLoading: true, cartError: nil) + } do { let cartList = try await getCartUseCase.execute() - uiState = uiState.copy( - cartList: cartList.items, - cartLoading: false, - cartError: nil - ) + await MainActor.run { + uiState = uiState.copy( + cartList: cartList.items, + cartLoading: false, + cartError: nil + ) + } } catch { - uiState = uiState.copy( - cartLoading: false, - cartError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + cartLoading: false, + cartError: error.localizedDescription + ) + } } print("CartListViewModel - loadCartList: \(uiState)") } @@ -95,19 +101,25 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isUpdating: true, updateError: nil) + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } do { try await updateCartQuantityUseCase.execute(cartItemId: cartItemId, quantity: quantity) - uiState = uiState.copy(isUpdating: false) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } print("CartListViewModel - updateQuantity success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadCartList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } print("CartListViewModel - updateQuantity error: \(error)") } } @@ -148,19 +160,25 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } do { try await deleteCartUseCase.execute(cartItemId: cartItemId) - uiState = uiState.copy(isDeleting: false) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } print("CartListViewModel - deleteCart success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadCartList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } print("CartListViewModel - deleteCart error: \(error)") } } @@ -191,19 +209,25 @@ class CartListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } do { try await deleteAllCartUseCase.execute() - uiState = uiState.copy(isDeleting: false) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } print("CartListViewModel - deleteAllCart success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadCartList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } print("CartListViewModel - deleteAllCart error: \(error)") } } diff --git a/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift index 241890b..421fcda 100644 --- a/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift +++ b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift @@ -27,12 +27,13 @@ class OutboundAPI { } // 출고 목록에 부품 추가 - func addOutbound(request: AddOutboundRequestDto) async throws -> Void { + func addOutbound(request: AddOutboundRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } let response = try await networkManager.request( endpoint: "agency/1/outbound", method: .post, - parameters: request.toDictionary(), - responseType: APIResponse.self + parameters: params, + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -40,11 +41,11 @@ class OutboundAPI { } // 출고 처리 - func processOutbound() async throws -> Void { + func processOutbound() async throws { let response = try await networkManager.request( endpoint: "agency/1/outbound/process", method: .post, - responseType: APIResponse.self + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -52,11 +53,11 @@ class OutboundAPI { } // 출고 항목 삭제 - func deleteOutbound(outboundId: Int) async throws -> Void { + func deleteOutbound(outboundId: Int) async throws { let response = try await networkManager.request( endpoint: "agency/1/outbound/\(outboundId)", method: .delete, - responseType: APIResponse.self + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -64,12 +65,13 @@ class OutboundAPI { } // 출고 수량 변경 - func updateOutbound(outboundId: Int, request: UpdateOutboundRequestDto) async throws -> Void { + func updateOutbound(outboundId: Int, request: UpdateOutboundRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } let response = try await networkManager.request( endpoint: "agency/1/outbound/\(outboundId)", method: .patch, - parameters: request.toDictionary(), - responseType: APIResponse.self + parameters: params, + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) @@ -77,11 +79,11 @@ class OutboundAPI { } // 출고 목록 전체 비우기 - func deleteAllOutbound() async throws -> Void { + func deleteAllOutbound() async throws { let response = try await networkManager.request( endpoint: "agency/1/outbound/clear", method: .delete, - responseType: APIResponse.self + responseType: EmptyResponse.self ) if !response.success { throw NetworkError.serverError(response.status) diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift b/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift index 1c50ed2..0050e31 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift @@ -43,12 +43,12 @@ struct OutboundListUiState { func copy( outboundList: [Outbound]? = nil, outboundLoading: Bool? = nil, - outboundError: String? = nil, + outboundError: String?? = nil, selectedOutbound: Outbound? = nil, isUpdating: Bool? = nil, - updateError: String? = nil, + updateError: String?? = nil, isDeleting: Bool? = nil, - deleteError: String? = nil, + deleteError: String?? = nil, isOrderSuccess: Bool? = nil ) -> OutboundListUiState { return OutboundListUiState( diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift index f6f5cdc..9e1c267 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift @@ -54,28 +54,34 @@ class OutboundListViewModel: ObservableObject { case .deleteAllOutbound: deleteAllOutbound() case .clearUpdateError: - uiState = uiState.copy(updateError: nil) + uiState = uiState.copy(updateError: .some(nil)) case .clearDeleteError: - uiState = uiState.copy(deleteError: nil) + uiState = uiState.copy(deleteError: .some(nil)) } } private func loadOutboundList() { Task { - uiState = uiState.copy(outboundLoading: true, outboundError: nil) + await MainActor.run { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + } do { let outboundList = try await getOutboundUseCase.execute() - uiState = uiState.copy( - outboundList: outboundList.items, - outboundLoading: false, - outboundError: nil - ) + await MainActor.run { + uiState = uiState.copy( + outboundList: outboundList.items, + outboundLoading: false, + outboundError: nil + ) + } } catch { - uiState = uiState.copy( - outboundLoading: false, - outboundError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + outboundLoading: false, + outboundError: error.localizedDescription + ) + } } print("OutboundListViewModel - loadOutboundList: \(uiState)") } @@ -83,17 +89,23 @@ class OutboundListViewModel: ObservableObject { private func processOutbound() { Task { - uiState = uiState.copy(outboundLoading: true, outboundError: nil) + await MainActor.run { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + } do { try await processOutboundUseCase.execute() - uiState = uiState.copy(outboundLoading: false, isOrderSuccess: true) + await MainActor.run { + uiState = uiState.copy(outboundLoading: false, isOrderSuccess: true) + } loadOutboundList() // 성공 후 리스트 새로고침 } catch { - uiState = uiState.copy( - outboundLoading: false, - outboundError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + outboundLoading: false, + outboundError: error.localizedDescription + ) + } } print("OutboundListViewModel - processOutbound: \(uiState)") } @@ -105,19 +117,25 @@ class OutboundListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isUpdating: true, updateError: nil) + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } do { try await updateOutboundQuantityUseCase.execute(outboundId: outboundId, quantity: quantity) - uiState = uiState.copy(isUpdating: false) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } print("OutboundListViewModel - updateQuantity success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } print("OutboundListViewModel - updateQuantity error: \(error)") } } @@ -158,19 +176,25 @@ class OutboundListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } do { try await deleteOutboundUseCase.execute(outboundId: outboundId) - uiState = uiState.copy(isDeleting: false) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } print("OutboundListViewModel - deleteOutbound success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } print("OutboundListViewModel - deleteOutbound error: \(error)") } } @@ -201,19 +225,25 @@ class OutboundListViewModel: ObservableObject { // 2. 백그라운드에서 서버 동기화 Task { - uiState = uiState.copy(isDeleting: true, deleteError: nil) + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } do { try await deleteAllOutboundUseCase.execute() - uiState = uiState.copy(isDeleting: false) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } print("OutboundListViewModel - deleteAllOutbound success: \(uiState)") } catch { // 3. 실패 시 원래 상태로 롤백하고 에러 표시 loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 - uiState = uiState.copy( - isDeleting: false, - deleteError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } print("OutboundListViewModel - deleteAllOutbound error: \(error)") } } diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index 51b5bff..e0b7e6a 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -10,12 +10,12 @@ struct PartDetailBottomSheetView: View { private func decreaseQuantity() { viewModel.onEvent(.decreaseQuantity) } private func increaseQuantity() { viewModel.onEvent(.increaseQuantity) } private func addToOutbound() { - let id = viewModel.uiState.part?.id ?? 0 + guard let id = viewModel.uiState.part?.id else { return } let qty = viewModel.uiState.quantity viewModel.onEvent(.addToOutbound(partId: id, quantity: qty)) } private func addToCart() { - let id = viewModel.uiState.part?.id ?? 0 + guard let id = viewModel.uiState.part?.id else { return } let qty = viewModel.uiState.quantity viewModel.onEvent(.addToCart(partId: id, quantity: qty)) } @@ -46,13 +46,13 @@ struct PartDetailBottomSheetView: View { handleUpdateError(newValue) } .alert(StringResources.PartDetail.confirmOutboundTitle, isPresented: $showOutboundDialog) { - Button(StringResources.Common.ok) { addToOutbound() } + Button(StringResources.Common.ok) { showOutboundDialog = false; addToOutbound() } Button(StringResources.Common.cancel, role: .cancel) { } } message: { Text(StringResources.PartDetail.confirmOutboundMessage) } .alert(StringResources.PartDetail.confirmCartTitle, isPresented: $showCartDialog) { - Button(StringResources.Common.ok) { addToCart() } + Button(StringResources.Common.ok) { showCartDialog = false; addToCart() } Button(StringResources.Common.cancel, role: .cancel) { } } message: { Text(StringResources.PartDetail.confirmCartMessage) diff --git a/SampoomManagement/Features/Part/UI/PartDetailUiState.swift b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift index a3b4b6c..08f3327 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailUiState.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift @@ -32,10 +32,10 @@ struct PartDetailUiState { } func copy( - part: Part? = nil, + part: Part?? = nil, quantity: Int? = nil, isUpdating: Bool? = nil, - updateError: String? = nil, + updateError: String?? = nil, isOutboundSuccess: Bool? = nil, isCartSuccess: Bool? = nil ) -> PartDetailUiState { diff --git a/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift index 36bd8c4..eb9388d 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift @@ -16,17 +16,11 @@ class PartDetailViewModel: ObservableObject { private let addOutboundUseCase: AddOutboundUseCase private let addCartUseCase: AddCartUseCase - private var errorLabel: String = "" - init(addOutboundUseCase: AddOutboundUseCase, addCartUseCase: AddCartUseCase) { self.addOutboundUseCase = addOutboundUseCase self.addCartUseCase = addCartUseCase } - func bindLabel(error: String) { - errorLabel = error - } - func onEvent(_ event: PartDetailUiEvent) { switch event { case .initialize(let part): @@ -59,30 +53,36 @@ class PartDetailViewModel: ObservableObject { addToCart(partId: partId, quantity: quantity) } case .clearError: - uiState = uiState.copy(updateError: nil) + uiState = uiState.copy(updateError: .some(nil)) case .dismiss: uiState = uiState.copy( - part: nil, + part: .some(nil), quantity: 1, - updateError: nil + updateError: .some(nil) ) } } private func addToOutbound(partId: Int, quantity: Int) { Task { - uiState = uiState.copy(isUpdating: true, updateError: nil) + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } do { try await addOutboundUseCase.execute(partId: partId, quantity: quantity) - uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) + } print("PartDetailViewModel - addToOutbound success: \(uiState)") } catch { - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } print("PartDetailViewModel - addToOutbound error: \(error)") } } @@ -90,18 +90,24 @@ class PartDetailViewModel: ObservableObject { private func addToCart(partId: Int, quantity: Int) { Task { - uiState = uiState.copy(isUpdating: true, updateError: nil) + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } do { try await addCartUseCase.execute(partId: partId, quantity: quantity) - uiState = uiState.copy(isUpdating: false, isCartSuccess: true) + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isCartSuccess: true) + } print("PartDetailViewModel - addToCart success: \(uiState)") } catch { - uiState = uiState.copy( - isUpdating: false, - updateError: error.localizedDescription - ) + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } print("PartDetailViewModel - addToCart error: \(error)") } } diff --git a/SampoomManagement/Features/Part/UI/PartListUiState.swift b/SampoomManagement/Features/Part/UI/PartListUiState.swift index 7a2d40b..43fda6c 100644 --- a/SampoomManagement/Features/Part/UI/PartListUiState.swift +++ b/SampoomManagement/Features/Part/UI/PartListUiState.swift @@ -28,8 +28,8 @@ struct PartListUiState { func copy( partList: [Part]? = nil, partListLoading: Bool? = nil, - partListError: String? = nil, - selectedPart: Part? = nil + partListError: String?? = nil, + selectedPart: Part?? = nil ) -> PartListUiState { return PartListUiState( partList: partList ?? self.partList, diff --git a/SampoomManagement/Features/Part/UI/PartListViewModel.swift b/SampoomManagement/Features/Part/UI/PartListViewModel.swift index 1cbeda7..532d2c3 100644 --- a/SampoomManagement/Features/Part/UI/PartListViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartListViewModel.swift @@ -36,7 +36,7 @@ class PartListViewModel: ObservableObject { case .showBottomSheet(let part): uiState = uiState.copy(selectedPart: part) case .dismissBottomSheet: - uiState = uiState.copy(selectedPart: nil) + uiState = uiState.copy(selectedPart: .some(nil)) } } @@ -47,7 +47,7 @@ class PartListViewModel: ObservableObject { guard let self else { return } // 로딩 상태 진입은 메인에서 await MainActor.run { - self.uiState = self.uiState.copy(partListLoading: true, partListError: nil) + self.uiState = self.uiState.copy(partListLoading: true, partListError: .some(nil)) } do { let partList = try await self.getPartUseCase.execute(groupId: self.groupId) @@ -56,7 +56,7 @@ class PartListViewModel: ObservableObject { self.uiState = self.uiState.copy( partList: partList.items, partListLoading: false, - partListError: nil + partListError: .some(nil) ) } } catch is CancellationError { From cba2bc4c12458cca3dfeae84a77b68e096077283 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Mon, 20 Oct 2025 11:17:04 +0900 Subject: [PATCH 4/5] =?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 --- .../Core/Resources/StringResources.swift | 2 + .../Core/UI/Components/AppHeader.swift | 9 ---- .../Core/UI/Components/CommonButton.swift | 54 ------------------- .../Core/UI/Components/CommonTextField.swift | 48 ----------------- .../Core/UI/Components/ErrorView.swift | 5 -- .../Core/UI/Components/LoadingView.swift | 3 -- .../Features/Cart/UI/CartListView.swift | 15 +++--- .../Features/Cart/UI/CartListViewModel.swift | 18 +++---- .../Outbound/UI/OutboundListView.swift | 32 +++-------- .../Outbound/UI/OutboundListViewModel.swift | 18 +++---- 10 files changed, 28 insertions(+), 176 deletions(-) diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 72bab97..7853912 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -83,6 +83,7 @@ struct StringResources { static let confirmProcessMessage = "선택하신 부품들을 출고 처리하시겠습니까?" static let confirmEmptyTitle = "전체 삭제" static let confirmEmptyMessage = "출고 목록을 모두 삭제하시겠습니까?" + static let deleteItemHint = "이 항목을 출고 목록에서 삭제합니다" } // MARK: - Cart @@ -98,6 +99,7 @@ struct StringResources { static let confirmEmptyTitle = "장바구니 비우기" static let confirmEmptyMessage = "장바구니를 비우시겠습니까?" static let emptyMessage = "장바구니가 비어있습니다" + static let deleteItemHint = "이 항목을 장바구니에서 삭제합니다" } // MARK: - PartDetail diff --git a/SampoomManagement/Core/UI/Components/AppHeader.swift b/SampoomManagement/Core/UI/Components/AppHeader.swift index 54799c8..a29e74d 100644 --- a/SampoomManagement/Core/UI/Components/AppHeader.swift +++ b/SampoomManagement/Core/UI/Components/AppHeader.swift @@ -46,12 +46,3 @@ struct AppHeader: View { } } -#Preview { - VStack { - AppHeader(title: "인벤토리") - AppHeader(title: "상세보기", showBackButton: true) { - print("Back pressed") - } - Spacer() - } -} diff --git a/SampoomManagement/Core/UI/Components/CommonButton.swift b/SampoomManagement/Core/UI/Components/CommonButton.swift index ed8aa63..28c465e 100644 --- a/SampoomManagement/Core/UI/Components/CommonButton.swift +++ b/SampoomManagement/Core/UI/Components/CommonButton.swift @@ -187,57 +187,3 @@ enum IconPosition { case trailing } -// MARK: - Preview -#Preview { - VStack(spacing: 16) { - // Filled Button (기본 보라색) - CommonButton("Button", type: .filled) { - print("Filled button tapped") - } - - // Filled Button with Custom Color - CommonButton("Button", type: .filled, backgroundColor: .blue, textColor: .white) { - print("Custom filled button tapped") - } - - // Filled Button with Icon - CommonButton("Button", type: .filled, icon: "phone.fill", backgroundColor: .green, textColor: .white) { - print("Filled button with icon tapped") - } - - // Outlined Button (기본 파란색) - CommonButton("Button", type: .outlined) { - print("Outlined button tapped") - } - - // Outlined Button with Custom Color - CommonButton("Button", type: .outlined, textColor: .red, borderColor: .red) { - print("Custom outlined button tapped") - } - - // Outlined Button (Gray) - CommonButton("Button", type: .outlined, textColor: .gray, borderColor: .gray) { - print("Gray outlined button tapped") - } - - // Disabled Button - CommonButton("Button", isEnabled: false) { - print("Disabled button tapped") - } - - // Size Examples - HStack(spacing: 16) { - CommonButton("Small", size: .small, backgroundColor: .orange) { } - CommonButton("Medium", size: .medium, backgroundColor: .purple) { } - CommonButton("Large", size: .large, backgroundColor: .pink) { } - } - - // Icon Position Examples - HStack(spacing: 16) { - CommonButton("Leading", icon: "star.fill", iconPosition: .leading, backgroundColor: .yellow, textColor: .black) { } - CommonButton("Trailing", type: .outlined, icon: "arrow.right", iconPosition: .trailing, textColor: .cyan, borderColor: .cyan) { } - } - } - .padding() - .background(Color.black) -} diff --git a/SampoomManagement/Core/UI/Components/CommonTextField.swift b/SampoomManagement/Core/UI/Components/CommonTextField.swift index e54500c..0b609c9 100644 --- a/SampoomManagement/Core/UI/Components/CommonTextField.swift +++ b/SampoomManagement/Core/UI/Components/CommonTextField.swift @@ -223,52 +223,4 @@ struct CommonTextField: View { } } -// MARK: - Preview -#Preview { - @Previewable @State var email = "" - @Previewable @State var password = "" - @Previewable @State var errorText = "Error" - - VStack(spacing: 16) { - // Email Input (Placeholder) - CommonTextField( - value: $email, - placeholder: "이메일 입력", - type: .email - ) { text in - print("Email: \(text)") - } - - // Password Input (Placeholder) - CommonTextField( - value: $password, - placeholder: "비밀번호 입력", - type: .password - ) { text in - print("Password: \(text)") - } - - // Error State - CommonTextField( - value: $errorText, - placeholder: "에러 상태", - type: .text, - isError: true, - errorMessage: "이메일 형식이 올바르지 않습니다." - ) { text in - print("Error: \(text)") - } - - // Custom Colors - CommonTextField( - value: $email, - placeholder: "커스텀 색상", - type: .text, - ) { text in - print("Custom: \(text)") - } - } - .padding() - .background(Color(.systemBackground)) -} diff --git a/SampoomManagement/Core/UI/Components/ErrorView.swift b/SampoomManagement/Core/UI/Components/ErrorView.swift index 3c81c11..fb9d949 100644 --- a/SampoomManagement/Core/UI/Components/ErrorView.swift +++ b/SampoomManagement/Core/UI/Components/ErrorView.swift @@ -32,8 +32,3 @@ struct ErrorView: View { } } -#Preview { - ErrorView(error: "네트워크 연결을 확인해주세요") { - print("Retry tapped") - } -} diff --git a/SampoomManagement/Core/UI/Components/LoadingView.swift b/SampoomManagement/Core/UI/Components/LoadingView.swift index 1717ac0..8f7d3a9 100644 --- a/SampoomManagement/Core/UI/Components/LoadingView.swift +++ b/SampoomManagement/Core/UI/Components/LoadingView.swift @@ -30,6 +30,3 @@ struct LoadingView: View { } } -#Preview { - LoadingView() -} diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index bb0c293..568fb4d 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -48,10 +48,8 @@ struct CartListView: View { ZStack(alignment: .bottom) { ScrollView { LazyVStack(spacing: 16) { - ForEach(viewModel.uiState.cartList.indices, id: \.self) { categoryIndex in - let category = viewModel.uiState.cartList[categoryIndex] - ForEach(category.groups.indices, id: \.self) { groupIndex in - let group = category.groups[groupIndex] + ForEach(viewModel.uiState.cartList, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in CartSection( categoryName: category.categoryName, groupName: group.groupName, @@ -114,19 +112,19 @@ struct CartListView: View { .onAppear { viewModel.onEvent(.loadCartList) } - .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in + .onChange(of: viewModel.uiState.isOrderSuccess) { _, newValue in if newValue { Toast.text(StringResources.Cart.orderSuccess).show() viewModel.clearSuccess() } } - .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in + .onChange(of: viewModel.uiState.updateError) { _, newValue in if let error = newValue { Toast.text("\(StringResources.Cart.updateQuantityError): \(error)").show() viewModel.onEvent(.clearUpdateError) } } - .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in + .onChange(of: viewModel.uiState.deleteError) { _, newValue in if let error = newValue { Toast.text("\(StringResources.Cart.deleteError): \(error)").show() viewModel.onEvent(.clearDeleteError) @@ -187,6 +185,9 @@ struct CartPartItem: View { } .frame(width: 44, height: 44) .disabled(isDeleting) + .accessibilityLabel(StringResources.Common.delete) + .accessibilityHint(StringResources.Cart.deleteItemHint) + .accessibilityIdentifier("cart_item_delete_\(part.cartItemId)") } .padding(16) diff --git a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift index dd4fbcc..f973277 100644 --- a/SampoomManagement/Features/Cart/UI/CartListViewModel.swift +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -19,8 +19,6 @@ class CartListViewModel: ObservableObject { private let deleteAllCartUseCase: DeleteAllCartUseCase // TODO: ProcessOrderUseCase 구현 후 주입 - private var errorLabel: String = "" - init( getCartUseCase: GetCartUseCase, updateCartQuantityUseCase: UpdateCartQuantityUseCase, @@ -33,10 +31,6 @@ class CartListViewModel: ObservableObject { self.deleteAllCartUseCase = deleteAllCartUseCase } - func bindLabel(error: String) { - errorLabel = error - } - func onEvent(_ event: CartListUiEvent) { switch event { case .loadCartList: @@ -112,14 +106,14 @@ class CartListViewModel: ObservableObject { } print("CartListViewModel - updateQuantity success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadCartList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isUpdating: false, updateError: error.localizedDescription ) } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 print("CartListViewModel - updateQuantity error: \(error)") } } @@ -171,14 +165,14 @@ class CartListViewModel: ObservableObject { } print("CartListViewModel - deleteCart success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadCartList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isDeleting: false, deleteError: error.localizedDescription ) } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 print("CartListViewModel - deleteCart error: \(error)") } } @@ -220,14 +214,14 @@ class CartListViewModel: ObservableObject { } print("CartListViewModel - deleteAllCart success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadCartList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isDeleting: false, deleteError: error.localizedDescription ) } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 print("CartListViewModel - deleteAllCart error: \(error)") } } diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift index b37d543..7987e2a 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -40,22 +40,21 @@ struct OutboundListView: View { .background(Color.background) .onAppear { viewModel.clearSuccess() - viewModel.bindLabel(error: "오류가 발생했습니다") viewModel.onEvent(.loadOutboundList) } - .onChange(of: viewModel.uiState.isOrderSuccess) { oldValue, newValue in + .onChange(of: viewModel.uiState.isOrderSuccess) { _, newValue in if newValue { Toast.text(StringResources.Outbound.orderSuccess).show() viewModel.clearSuccess() } } - .onChange(of: viewModel.uiState.updateError) { oldValue, newValue in + .onChange(of: viewModel.uiState.updateError) { _, newValue in if let error = newValue { Toast.text("\(StringResources.Outbound.updateQuantityError): \(error)").show() viewModel.onEvent(.clearUpdateError) } } - .onChange(of: viewModel.uiState.deleteError) { oldValue, newValue in + .onChange(of: viewModel.uiState.deleteError) { _, newValue in if let error = newValue { Toast.text("\(StringResources.Outbound.deleteError): \(error)").show() viewModel.onEvent(.clearDeleteError) @@ -202,6 +201,9 @@ struct OutboundPartItem: View { } .frame(width: 44, height: 44) .disabled(isDeleting) + .accessibilityLabel(StringResources.Common.delete) + .accessibilityHint(StringResources.Outbound.deleteItemHint) + .accessibilityIdentifier("outbound_item_delete_\(part.outboundId)") } .padding(16) @@ -244,25 +246,3 @@ struct OutboundPartItem: View { } } -#Preview { - OutboundListView(viewModel: OutboundListViewModel( - getOutboundUseCase: GetOutboundUseCase(repository: MockOutboundRepository()), - processOutboundUseCase: ProcessOutboundUseCase(repository: MockOutboundRepository()), - updateOutboundQuantityUseCase: UpdateOutboundQuantityUseCase(repository: MockOutboundRepository()), - deleteOutboundUseCase: DeleteOutboundUseCase(repository: MockOutboundRepository()), - deleteAllOutboundUseCase: DeleteAllOutboundUseCase(repository: MockOutboundRepository()) - )) -} - -// Preview용 Mock Repository -class MockOutboundRepository: OutboundRepository { - func getOutboundList() async throws -> OutboundList { - return OutboundList.empty() - } - - func processOutbound() async throws {} - func addOutbound(partId: Int, quantity: Int) async throws {} - func deleteOutbound(outboundId: Int) async throws {} - func deleteAllOutbound() async throws {} - func updateOutboundQuantity(outboundId: Int, quantity: Int) async throws {} -} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift index 9e1c267..4dad873 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift @@ -19,8 +19,6 @@ class OutboundListViewModel: ObservableObject { private let deleteOutboundUseCase: DeleteOutboundUseCase private let deleteAllOutboundUseCase: DeleteAllOutboundUseCase - private var errorLabel: String = "" - init( getOutboundUseCase: GetOutboundUseCase, processOutboundUseCase: ProcessOutboundUseCase, @@ -35,10 +33,6 @@ class OutboundListViewModel: ObservableObject { self.deleteAllOutboundUseCase = deleteAllOutboundUseCase } - func bindLabel(error: String) { - errorLabel = error - } - func onEvent(_ event: OutboundListUiEvent) { switch event { case .loadOutboundList: @@ -128,14 +122,14 @@ class OutboundListViewModel: ObservableObject { } print("OutboundListViewModel - updateQuantity success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isUpdating: false, updateError: error.localizedDescription ) } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 print("OutboundListViewModel - updateQuantity error: \(error)") } } @@ -187,14 +181,14 @@ class OutboundListViewModel: ObservableObject { } print("OutboundListViewModel - deleteOutbound success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isDeleting: false, deleteError: error.localizedDescription ) } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 print("OutboundListViewModel - deleteOutbound error: \(error)") } } @@ -236,14 +230,14 @@ class OutboundListViewModel: ObservableObject { } print("OutboundListViewModel - deleteAllOutbound success: \(uiState)") } catch { - // 3. 실패 시 원래 상태로 롤백하고 에러 표시 - loadOutboundList() // 서버에서 최신 상태 가져와서 롤백 + // 3. 실패 시 에러 표시 후 롤백 await MainActor.run { uiState = uiState.copy( isDeleting: false, deleteError: error.localizedDescription ) } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 print("OutboundListViewModel - deleteAllOutbound error: \(error)") } } From ec819c3f1d31be2ad0ae23a8a210ceaa35e01c85 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Mon, 20 Oct 2025 11:25:39 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[FIX]=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Part/UI/PartDetailBottomSheetView.swift | 9 ++------- SampoomManagement/Features/Part/UI/PartListView.swift | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index e0b7e6a..d9bcd3b 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -62,6 +62,8 @@ struct PartDetailBottomSheetView: View { private var mainContent: some View { NavigationView { VStack(alignment: .leading, spacing: 16) { + Spacer() + PartInfoHeaderView( name: partName, code: partCode, @@ -86,13 +88,6 @@ struct PartDetailBottomSheetView: View { } .padding(24) .background(Color.background) - .navigationTitle(StringResources.PartDetail.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(StringResources.Navigation.close) { viewModel.onEvent(.dismiss) } - } - } } } diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift index 1c0a572..848a9fd 100644 --- a/SampoomManagement/Features/Part/UI/PartListView.swift +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -81,7 +81,7 @@ struct PartListView: View { viewModel.onEvent(.dismissBottomSheet) } .presentationDetents([.fraction(0.3)]) - .presentationDragIndicator(.automatic) + .presentationDragIndicator(.visible) .presentationBackground(.clear) } }