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..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 "잘못된 매개변수입니다" } } } @@ -34,6 +37,7 @@ enum AuthError: Error, LocalizedError { case tokenSaveFailed(Error) case invalidCredentials case networkError(Error) + case invalidResponse var errorDescription: String? { switch self { @@ -43,6 +47,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..a8998b4 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -19,33 +19,40 @@ 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 + + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { continuation in + let dataRequest = AF.request( + url, + method: method, + parameters: parameters, + encoding: JSONEncoding.default + ) - AF.request( - url, - method: method, - parameters: parameters, - encoding: JSONEncoding.default - ) - .responseData { response in - switch response.result { - case .success(let data): - Task { @MainActor in - do { - let apiResponse = try JSONDecoder().decode(APIResponse.self, from: data) - completion(.success(apiResponse)) - } catch { - completion(.failure(.decodingError(error))) + 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): - Task { @MainActor in - completion(.failure(.networkError(error))) - } } - } + }, onCancel: { + // Task cancellation handled by Alamofire automatically + }) } } diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 783d612..7853912 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 = "부품조회" @@ -71,6 +71,58 @@ 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 = "출고 목록을 모두 삭제하시겠습니까?" + static let deleteItemHint = "이 항목을 출고 목록에서 삭제합니다" + } + + // 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 = "장바구니가 비어있습니다" + static let deleteItemHint = "이 항목을 장바구니에서 삭제합니다" + } + + // 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/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 a86c0b0..28c465e 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,13 @@ 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) + .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) } @@ -84,7 +93,13 @@ struct CommonButton: View { Text(title) .font(.gmarketBody) - if let icon = icon, iconPosition == .trailing { + if let customIcon = customIcon, iconPosition == .trailing { + Image(customIcon) + .renderingMode(.template) + .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) } @@ -172,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/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..cb2ba5a --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift @@ -0,0 +1,81 @@ +// +// 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 { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/1/cart", + method: .post, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 항목 삭제 + func deleteCart(cartItemId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/1/cart/\(cartItemId)", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 수량 변경 + 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: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 장바구니 전체 비우기 + func deleteAllCart() async throws { + let response = try await networkManager.request( + endpoint: "agency/1/cart/clear", + method: .delete, + responseType: EmptyResponse.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..9f10611 --- /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..568fb4d --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -0,0 +1,230 @@ +// +// 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 { + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } 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: StringResources.Cart.emptyMessage) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack(alignment: .bottom) { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.uiState.cartList, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in + 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(StringResources.Cart.processOrder, backgroundColor: .accentColor, textColor: .white) { + showConfirmDialog = true + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + } + } + .navigationTitle(StringResources.Cart.title) + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !viewModel.uiState.cartLoading && viewModel.uiState.cartError == nil && !viewModel.uiState.cartList.isEmpty { + Button(StringResources.Cart.emptyAll) { + showEmptyCartDialog = true + } + .foregroundColor(.red) + } + } + } + .background(Color.background) + .alert(StringResources.Cart.confirmEmptyTitle, isPresented: $showEmptyCartDialog) { + Button(StringResources.Common.ok) { + viewModel.onEvent(.deleteAllCart) + } + Button(StringResources.Common.cancel, role: .cancel) { } + } message: { + Text(StringResources.Cart.confirmEmptyMessage) + } + .alert(StringResources.Cart.confirmProcessTitle, isPresented: $showConfirmDialog) { + Button(StringResources.Common.ok) { + viewModel.onEvent(.processOrder) + } + Button(StringResources.Common.cancel, role: .cancel) { } + } message: { + Text(StringResources.Cart.confirmProcessMessage) + } + .onAppear { + viewModel.onEvent(.loadCartList) + } + .onChange(of: viewModel.uiState.isOrderSuccess) { _, newValue in + if newValue { + Toast.text(StringResources.Cart.orderSuccess).show() + viewModel.clearSuccess() + } + } + .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) { _, newValue in + if let error = newValue { + Toast.text("\(StringResources.Cart.deleteError): \(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) + .accessibilityLabel(StringResources.Common.delete) + .accessibilityHint(StringResources.Cart.deleteItemHint) + .accessibilityIdentifier("cart_item_delete_\(part.cartItemId)") + } + .padding(16) + + // 수량 조절 + HStack { + Text(StringResources.Part.quantity) + .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..f973277 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -0,0 +1,238 @@ +// +// 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 구현 후 주입 + + init( + getCartUseCase: GetCartUseCase, + updateCartQuantityUseCase: UpdateCartQuantityUseCase, + deleteCartUseCase: DeleteCartUseCase, + deleteAllCartUseCase: DeleteAllCartUseCase + ) { + self.getCartUseCase = getCartUseCase + self.updateCartQuantityUseCase = updateCartQuantityUseCase + self.deleteCartUseCase = deleteCartUseCase + self.deleteAllCartUseCase = deleteAllCartUseCase + } + + 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: .some(nil)) + case .clearDeleteError: + uiState = uiState.copy(deleteError: .some(nil)) + } + } + + private func loadCartList() { + Task { + await MainActor.run { + uiState = uiState.copy(cartLoading: true, cartError: nil) + } + + do { + let cartList = try await getCartUseCase.execute() + + await MainActor.run { + uiState = uiState.copy( + cartList: cartList.items, + cartLoading: false, + cartError: nil + ) + } + } catch { + await MainActor.run { + 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 { + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } + + do { + try await updateCartQuantityUseCase.execute(cartItemId: cartItemId, quantity: quantity) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + print("CartListViewModel - updateQuantity success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + 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 { + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } + + do { + try await deleteCartUseCase.execute(cartItemId: cartItemId) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + print("CartListViewModel - deleteCart success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + 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 { + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } + + do { + try await deleteAllCartUseCase.execute() + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + print("CartListViewModel - deleteAllCart success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + 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..421fcda --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/API/OutboundAPI.swift @@ -0,0 +1,100 @@ +// +// 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 { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/1/outbound", + method: .post, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 처리 + func processOutbound() async throws { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/process", + method: .post, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 항목 삭제 + func deleteOutbound(outboundId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/\(outboundId)", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 수량 변경 + 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: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status) + } + } + + // 출고 목록 전체 비우기 + func deleteAllOutbound() async throws { + let response = try await networkManager.request( + endpoint: "agency/1/outbound/clear", + method: .delete, + responseType: EmptyResponse.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..0050e31 --- /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..7987e2a --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -0,0 +1,248 @@ +// +// 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(StringResources.Outbound.title) + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !viewModel.uiState.outboundList.isEmpty && + !viewModel.uiState.outboundLoading && + viewModel.uiState.outboundError == nil { + Button(StringResources.Outbound.emptyAll) { + showEmptyOutboundDialog = true + } + .foregroundColor(.red) + } + } + } + .background(Color.background) + .onAppear { + viewModel.clearSuccess() + viewModel.onEvent(.loadOutboundList) + } + .onChange(of: viewModel.uiState.isOrderSuccess) { _, newValue in + if newValue { + Toast.text(StringResources.Outbound.orderSuccess).show() + viewModel.clearSuccess() + } + } + .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) { _, newValue in + if let error = newValue { + Toast.text("\(StringResources.Outbound.deleteError): \(error)").show() + viewModel.onEvent(.clearDeleteError) + } + } + .alert(StringResources.Outbound.confirmEmptyTitle, isPresented: $showEmptyOutboundDialog) { + Button(StringResources.Common.cancel, role: .cancel) { } + Button(StringResources.Common.ok) { + viewModel.onEvent(.deleteAllOutbound) + } + } message: { + Text(StringResources.Outbound.confirmEmptyMessage) + } + .alert(StringResources.Outbound.confirmProcessTitle, isPresented: $showConfirmDialog) { + Button(StringResources.Common.cancel, role: .cancel) { } + Button(StringResources.Common.ok) { + viewModel.onEvent(.processOutbound) + } + } message: { + Text(StringResources.Outbound.confirmProcessMessage) + } + } + + @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(StringResources.Outbound.processOrder, 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) + .accessibilityLabel(StringResources.Common.delete) + .accessibilityHint(StringResources.Outbound.deleteItemHint) + .accessibilityIdentifier("outbound_item_delete_\(part.outboundId)") + } + .padding(16) + + // 수량 조절 + HStack { + Text(StringResources.Part.quantity) + .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) + ) + } +} + diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift new file mode 100644 index 0000000..4dad873 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift @@ -0,0 +1,253 @@ +// +// 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 + + 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 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: .some(nil)) + case .clearDeleteError: + uiState = uiState.copy(deleteError: .some(nil)) + } + } + + private func loadOutboundList() { + Task { + await MainActor.run { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + } + + do { + let outboundList = try await getOutboundUseCase.execute() + await MainActor.run { + uiState = uiState.copy( + outboundList: outboundList.items, + outboundLoading: false, + outboundError: nil + ) + } + } catch { + await MainActor.run { + uiState = uiState.copy( + outboundLoading: false, + outboundError: error.localizedDescription + ) + } + } + print("OutboundListViewModel - loadOutboundList: \(uiState)") + } + } + + private func processOutbound() { + Task { + await MainActor.run { + uiState = uiState.copy(outboundLoading: true, outboundError: nil) + } + + do { + try await processOutboundUseCase.execute() + await MainActor.run { + uiState = uiState.copy(outboundLoading: false, isOrderSuccess: true) + } + loadOutboundList() // 성공 후 리스트 새로고침 + } catch { + await MainActor.run { + 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 { + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } + + do { + try await updateOutboundQuantityUseCase.execute(outboundId: outboundId, quantity: quantity) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + print("OutboundListViewModel - updateQuantity success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 + 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 { + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } + + do { + try await deleteOutboundUseCase.execute(outboundId: outboundId) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + print("OutboundListViewModel - deleteOutbound success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 + 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 { + await MainActor.run { + uiState = uiState.copy(isDeleting: true, deleteError: nil) + } + + do { + try await deleteAllOutboundUseCase.execute() + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + print("OutboundListViewModel - deleteAllOutbound success: \(uiState)") + } catch { + // 3. 실패 시 에러 표시 후 롤백 + await MainActor.run { + uiState = uiState.copy( + isDeleting: false, + deleteError: error.localizedDescription + ) + } + loadOutboundList() // 에러 표시 후 백그라운드에서 롤백 + 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..d9bcd3b --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -0,0 +1,207 @@ +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() { + guard let id = viewModel.uiState.part?.id else { return } + let qty = viewModel.uiState.quantity + viewModel.onEvent(.addToOutbound(partId: id, quantity: qty)) + } + private func addToCart() { + guard let id = viewModel.uiState.part?.id else { return } + 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 { "\(StringResources.PartDetail.currentQuantity): \(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(StringResources.PartDetail.confirmOutboundTitle, isPresented: $showOutboundDialog) { + 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) { showCartDialog = false; addToCart() } + Button(StringResources.Common.cancel, role: .cancel) { } + } message: { + Text(StringResources.PartDetail.confirmCartMessage) + } + } + + private var mainContent: some View { + NavigationView { + VStack(alignment: .leading, spacing: 16) { + Spacer() + + 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) + } + } + + 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(StringResources.PartDetail.outboundSuccess).show() + showOutboundDialog = false + viewModel.clearSuccess() + } + } + + private func handleCartSuccess(_ newValue: Bool) { + if newValue { + Toast.text(StringResources.PartDetail.cartSuccess).show() + showCartDialog = false + viewModel.clearSuccess() + } + } + + private func handleUpdateError(_ newValue: String?) { + if let error = newValue { + Toast.text("\(StringResources.PartDetail.errorOccurred): \(error)").show() + viewModel.onEvent(.clearError) + } + } +} + +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(StringResources.PartDetail.quantity) + .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(StringResources.PartDetail.addToOutbound, customIcon: "outbound", backgroundColor: .red, textColor: .white) { + addOutboundAction() + } + .disabled(isDisabled) + + CommonButton(StringResources.PartDetail.addToCart, 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..08f3327 --- /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..eb9388d --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift @@ -0,0 +1,119 @@ +// +// 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 + + init(addOutboundUseCase: AddOutboundUseCase, addCartUseCase: AddCartUseCase) { + self.addOutboundUseCase = addOutboundUseCase + self.addCartUseCase = addCartUseCase + } + + 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: .some(nil)) + case .dismiss: + uiState = uiState.copy( + part: .some(nil), + quantity: 1, + updateError: .some(nil) + ) + } + } + + private func addToOutbound(partId: Int, quantity: Int) { + Task { + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } + + do { + try await addOutboundUseCase.execute(partId: partId, quantity: quantity) + + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) + } + print("PartDetailViewModel - addToOutbound success: \(uiState)") + } catch { + await MainActor.run { + uiState = uiState.copy( + isUpdating: false, + updateError: error.localizedDescription + ) + } + print("PartDetailViewModel - addToOutbound error: \(error)") + } + } + } + + private func addToCart(partId: Int, quantity: Int) { + Task { + await MainActor.run { + uiState = uiState.copy(isUpdating: true, updateError: nil) + } + + do { + try await addCartUseCase.execute(partId: partId, quantity: quantity) + + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isCartSuccess: true) + } + print("PartDetailViewModel - addToCart success: \(uiState)") + } catch { + await MainActor.run { + 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..43fda6c 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..848a9fd 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(.visible) + .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..532d2c3 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: .some(nil)) } } @@ -41,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) @@ -50,7 +56,7 @@ class PartListViewModel: ObservableObject { self.uiState = self.uiState.copy( partList: partList.items, partListLoading: false, - partListError: nil + partListError: .some(nil) ) } } catch is CancellationError { @@ -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