diff --git a/.github/workflows/pr-reminder.yml b/.github/workflows/pr-reminder.yml index 9190e01..007e757 100644 --- a/.github/workflows/pr-reminder.yml +++ b/.github/workflows/pr-reminder.yml @@ -1,13 +1,15 @@ -name: PR Reminder + name: PR Reminder -on: - schedule: - - cron: "0 0,5,8 * * *" # 아침 9시, 오후 2시, 오후 5시에 실행 (UTC 기준으로 설정해서 한국 시간에 맞춤) - workflow_dispatch: + on: + schedule: + - cron: "47 23,4,7,8,10 * * *" # 아침 8시 47분, 오후 2시 47분, 오후 4시 47분, 오후 5시 47분, 오후 7시 47분 에 실행 (UTC 기준으로 설정해서 한국 시간에 맞춤) + workflow_dispatch: -jobs: - call-reusable-reminder: - uses: 33-Auto/.github/.github/workflows/reusable-pr-reminder.yml@main - secrets: - # 해당 시크릿은 조직의 시크릿에 저장되어 있음 - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + jobs: + call-reusable-reminder: + uses: 33-Auto/.github/.github/workflows/reusable-pr-reminder.yml@main + secrets: + # 해당 시크릿은 조직의 시크릿에 저장되어 있음 + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + with: + SLACK_USER_MAP: ${{ vars.SLACK_USER_MAP }} \ No newline at end of file diff --git a/SampoomManagement.xcodeproj/project.pbxproj b/SampoomManagement.xcodeproj/project.pbxproj index ea94f0a..986609b 100644 --- a/SampoomManagement.xcodeproj/project.pbxproj +++ b/SampoomManagement.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 533528342E8BD99400F38FD1 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 533528332E8BD99400F38FD1 /* Alamofire */; }; 5387CA3A2E8F676E005A3936 /* Swinject in Frameworks */ = {isa = PBXBuildFile; productRef = 5387CA392E8F676E005A3936 /* Swinject */; }; + 53F27C452E9F9C8800D223ED /* Toast in Frameworks */ = {isa = PBXBuildFile; productRef = 53F27C442E9F9C8800D223ED /* Toast */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -19,7 +20,7 @@ 533528372E8BDAB300F38FD1 /* Exceptions for "SampoomManagement" folder in "SampoomManagement" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Resources/Info.plist, + Info.plist, ); target = 53A7B4BE2E8A43AF00BC946E /* SampoomManagement */; }; @@ -41,6 +42,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 53F27C452E9F9C8800D223ED /* Toast in Frameworks */, 533528342E8BD99400F38FD1 /* Alamofire in Frameworks */, 5387CA3A2E8F676E005A3936 /* Swinject in Frameworks */, ); @@ -87,6 +89,7 @@ packageProductDependencies = ( 533528332E8BD99400F38FD1 /* Alamofire */, 5387CA392E8F676E005A3936 /* Swinject */, + 53F27C442E9F9C8800D223ED /* Toast */, ); productName = SampoomManagement; productReference = 53A7B4BF2E8A43AF00BC946E /* SampoomManagement.app */; @@ -119,6 +122,7 @@ packageReferences = ( 533528322E8BD99400F38FD1 /* XCRemoteSwiftPackageReference "Alamofire" */, 5387CA382E8F676E005A3936 /* XCRemoteSwiftPackageReference "Swinject" */, + 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 53A7B4C02E8A43AF00BC946E /* Products */; @@ -210,6 +214,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -267,6 +272,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -278,10 +284,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SampoomManagement/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "삼품관리"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -312,10 +319,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = B9PUAVBBKX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SampoomManagement/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "삼품관리"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -380,6 +388,14 @@ minimumVersion = 2.10.0; }; }; + 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/BastiaanJansen/toast-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.3; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -393,6 +409,11 @@ package = 5387CA382E8F676E005A3936 /* XCRemoteSwiftPackageReference "Swinject" */; productName = Swinject; }; + 53F27C442E9F9C8800D223ED /* Toast */ = { + isa = XCSwiftPackageProductDependency; + package = 53F27C432E9F9C8800D223ED /* XCRemoteSwiftPackageReference "toast-swift" */; + productName = Toast; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 53A7B4B72E8A43AF00BC946E /* Project object */; diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index f22696f..6e49bfc 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -8,145 +8,184 @@ import SwiftUI enum Tabs { - case dashboard, delivery, cart, orders, parts + case dashboard, outbound, cart, orders, parts +} + +enum SettingNavigation: Hashable { + case settings } struct ContentView: View { + // MARK: - Properties + let dependencies: AppDependencies @StateObject private var partViewModel: PartViewModel + @StateObject private var dashboardViewModel: DashboardViewModel @State private var selectedTab: Tabs = .dashboard + @State private var ordersNavigationPath = NavigationPath() + @State private var partsNavigationPath = NavigationPath() + @State private var dashboardNavigationPath = NavigationPath() - init() { - // DI Container에서 ViewModel 주입 - guard let viewModel = DIContainer.shared.resolve(PartViewModel.self) else { - fatalError("PartViewModel을 DIContainer에서 찾을 수 없습니다.") - } - _partViewModel = StateObject(wrappedValue: viewModel) + // MARK: - Initialization + init(dependencies: AppDependencies) { + self.dependencies = dependencies + _partViewModel = StateObject(wrappedValue: dependencies.makePartViewModel()) + _dashboardViewModel = StateObject(wrappedValue: dependencies.makeDashboardViewModel()) } + // MARK: - Body var body: some View { - TabView(selection: $selectedTab) { - // Dashboard 탭 (임시) - Tab(value: .dashboard) { - NavigationStack { - VStack(spacing: 20) { - Spacer() + ZStack { + // 전체 백그라운드 + Color.background + .ignoresSafeArea(.all) + + TabView(selection: $selectedTab) { + // Dashboard 탭 (DashboardView directly) + Tab(value: .dashboard) { + NavigationStack(path: $dashboardNavigationPath) { + let user = try? dependencies.authPreferences.getStoredUser() + DashboardView( + viewModel: dashboardViewModel, + onLogoutClick: { + Task { await dependencies.authViewModel.signOut() } + }, + onNavigateOrderDetail: { order in + selectedTab = .orders + DispatchQueue.main.async { + ordersNavigationPath.append(order.orderId) + } + }, + onNavigateOrderList: { + selectedTab = .orders + }, + onSettingClick: { + dashboardNavigationPath.append(SettingNavigation.settings) + }, + userName: user?.name ?? "", + branch: user?.branch ?? "", + userRole: user?.role ?? .user + ) + .navigationDestination(for: SettingNavigation.self) { destination in + switch destination { + case .settings: + SettingView( + viewModel: dependencies.makeSettingViewModel(), + onNavigateBack: { + if !dashboardNavigationPath.isEmpty { + dashboardNavigationPath.removeLast() + } + }, + onLogoutClick: { + dependencies.authViewModel.handleSignedOutState() + } + ) + } + } + } + } label: { + Label { Text(StringResources.Tabs.dashboard) - .font(.largeTitle) - .fontWeight(.bold) - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() + .font(.gmarketSubheadline) + } icon: { + Image("dashboard") + .renderingMode(.template) + .foregroundStyle(.text) } - .navigationTitle(StringResources.Tabs.dashboard) - } - } label: { - Label { - Text(StringResources.Tabs.dashboard) - } icon: { - Image("dashboard") - .renderingMode(.template) - .foregroundStyle(Color.text) } - } - - // Delivery 탭 (임시) - Tab(value: .delivery) { - 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() + + // Outbound 탭 + Tab(value: .outbound) { + NavigationStack { + OutboundListView(viewModel: dependencies.makeOutboundListViewModel()) + } + } label: { + Label { + Text(StringResources.Tabs.outbound) + .font(.gmarketSubheadline) + } icon: { + Image("outbound") + .renderingMode(.template) + .foregroundStyle(Color.text) } - .navigationTitle(StringResources.Tabs.delivery) - } - } label: { - Label { - Text(StringResources.Tabs.delivery) - } icon: { - Image("delivery") - .renderingMode(.template) - .foregroundStyle(Color.text) } - } - - // Cart 탭 (임시) - Tab(value: .cart) { - NavigationStack { - VStack(spacing: 20) { - Spacer() + + // Cart 탭 + Tab(value: .cart) { + NavigationStack { + CartListView( + viewModel: dependencies.makeCartListViewModel(), + dependencies: dependencies + ) + } + } label: { + Label { Text(StringResources.Tabs.cart) - .font(.largeTitle) - .fontWeight(.bold) - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() + .font(.gmarketSubheadline) + } icon: { + Image("cart") + .renderingMode(.template) + .foregroundStyle(Color.text) } - .navigationTitle(StringResources.Tabs.cart) - } - } label: { - Label { - Text(StringResources.Tabs.cart) - } icon: { - Image("cart") - .renderingMode(.template) - .foregroundStyle(Color.text) } - } - - // Orders 탭 (임시) - Tab(value: .orders) { - NavigationStack { - VStack(spacing: 20) { - Spacer() + + // Orders 탭 + Tab(value: .orders) { + NavigationStack(path: $ordersNavigationPath) { + OrderListView( + viewModel: dependencies.makeOrderListViewModel(), + onNavigateOrderDetail: { orderId in + ordersNavigationPath.append(orderId) + } + ) + .navigationDestination(for: Int.self) { orderId in + OrderDetailView( + orderId: orderId, + viewModel: dependencies.makeOrderDetailViewModel(orderId: orderId) + ) + } + } + } label: { + Label { Text(StringResources.Tabs.orders) - .font(.largeTitle) - .fontWeight(.bold) - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() + .font(.gmarketSubheadline) + } icon: { + Image("orders") + .renderingMode(.template) + .foregroundStyle(Color.text) } - .navigationTitle(StringResources.Tabs.orders) } - } label: { - Label { - Text(StringResources.Tabs.orders) - } icon: { - Image("orders") - .renderingMode(.template) - .foregroundStyle(Color.text) - } - } - - // PartView 탭 - Tab(value: .parts, role: .search) { - PartView() + + // PartView 탭 + Tab(value: .parts, role: .search) { + NavigationStack(path: $partsNavigationPath) { + PartView( + onNavigatePartList: { group in + partsNavigationPath.append(group.id) + }, + viewModel: partViewModel, + searchViewModel: dependencies.makeSearchViewModel() + ) + .navigationDestination(for: Int.self) { groupId in + PartListView( + viewModel: dependencies.makePartListViewModel(groupId: groupId), + dependencies: dependencies + ) + } + } .environmentObject(partViewModel) - } label: { - Label { - Text(StringResources.Tabs.parts) - } icon: { - Image("parts") - .renderingMode(.template) - .foregroundStyle(Color.text) + } label: { + Label { + Text(StringResources.Tabs.parts) + .font(.gmarketSubheadline) + } icon: { + Image("parts") + .renderingMode(.template) + .foregroundStyle(Color.text) + } } } + .accentColor(.accentColor) + .tabViewStyle(.automatic) } - .accentColor(.blue) } } diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift new file mode 100644 index 0000000..e05c8c4 --- /dev/null +++ b/SampoomManagement/App/RootView.swift @@ -0,0 +1,87 @@ +// +// RootView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI +import Toast + +struct RootView: View { + let dependencies: AppDependencies + + @StateObject private var loginViewModel: LoginViewModel + @StateObject private var signUpViewModel: SignUpViewModel + @ObservedObject private var authViewModel: AuthViewModel + @ObservedObject private var globalMessageHandler: GlobalMessageHandler + @State private var showSignUp: Bool = false + + init(dependencies: AppDependencies) { + self.dependencies = dependencies + _loginViewModel = StateObject(wrappedValue: dependencies.makeLoginViewModel()) + _signUpViewModel = StateObject(wrappedValue: dependencies.makeSignUpViewModel()) + self.authViewModel = dependencies.authViewModel + self.globalMessageHandler = dependencies.globalMessageHandler + } + + var body: some View { + ZStack { + // 전체 백그라운드 + Color.background + .ignoresSafeArea(.all) + + Group { + if authViewModel.isLoggedIn { + // 로그인 되어있으면 메인 화면 + ContentView(dependencies: dependencies) + } else { + // 로그인 안되어있으면 로그인/회원가입 화면 + if showSignUp { + NavigationStack { + SignUpView( + viewModel: signUpViewModel, + onSuccess: { + // 회원가입 성공 시 자동 로그인 완료 → 메인 화면으로 + authViewModel.updateLoginState() + } + ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + showSignUp = false + }) { + Image(systemName: "chevron.left") + .foregroundColor(Color(red: 0.5, green: 0.2, blue: 0.8)) + } + } + } + } + } else { + LoginView( + viewModel: loginViewModel, + onSuccess: { + // 로그인 성공 시 메인 화면으로 + authViewModel.updateLoginState() + }, + onNavigateSignUp: { + // 회원가입 화면으로 + showSignUp = true + } + ) + } + } + } + + // Toast 컨테이너 (앱 최상단에 배치) + ToastContainer(globalMessageHandler: globalMessageHandler) + } + .onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in + if shouldNavigate { + showSignUp = false + authViewModel.resetNavigationState() + } + } + } +} diff --git a/SampoomManagement/App/SampoomManagementApp.swift b/SampoomManagement/App/SampoomManagementApp.swift index c04c678..8d52609 100644 --- a/SampoomManagement/App/SampoomManagementApp.swift +++ b/SampoomManagement/App/SampoomManagementApp.swift @@ -9,15 +9,44 @@ import SwiftUI @main struct SampoomManagementApp: App { + // SwiftUI Environment 기반 DI + private let dependencies = AppDependencies() init() { - // DI Container 초기화 - _ = DIContainer.shared + // 앱 전체 폰트 설정 + setupGlobalFont() + // 앱 전체 백그라운드 설정 + setupGlobalBackground() } var body: some Scene { WindowGroup { - ContentView() + RootView(dependencies: dependencies) + } + } + + // MARK: - Setup + + private func setupGlobalFont() { + // UIKit 컴포넌트에 대한 기본 폰트 설정 + if let font = UIFont.gmarketSans(size: 16, weight: .medium) { + UILabel.appearance().font = font + UITextField.appearance().font = font + UITextView.appearance().font = font + } + } + + private func setupGlobalBackground() { + // UIKit 컴포넌트에 대한 기본 백그라운드 설정 + UITableView.appearance().backgroundColor = UIColor.clear + UICollectionView.appearance().backgroundColor = UIColor.clear + UINavigationBar.appearance().backgroundColor = UIColor.clear + UITabBar.appearance().backgroundColor = UIColor.clear + + // 시스템 배경색 설정 + if let backgroundColor = UIColor(named: "Background") { + UINavigationBar.appearance().barTintColor = backgroundColor + UITabBar.appearance().barTintColor = backgroundColor } } } diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift new file mode 100644 index 0000000..55b9bfa --- /dev/null +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -0,0 +1,249 @@ +// +// AppDependencies.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import SwiftUI + +/// SwiftUI Environment 기반 의존성 관리 +class AppDependencies { + // MARK: - Core + let networkManager: NetworkManager + let globalMessageHandler: GlobalMessageHandler + + // MARK: - Auth + let authPreferences: AuthPreferences + let authAPI: AuthAPI + let authRepository: AuthRepository + let loginUseCase: LoginUseCase + let signUpUseCase: SignUpUseCase + let checkLoginStateUseCase: CheckLoginStateUseCase + let signOutUseCase: SignOutUseCase + let clearTokensUseCase: ClearTokensUseCase + let getVendorUseCase: GetVendorUseCase + let authViewModel: AuthViewModel + + // MARK: - Network Auth + private let tokenRefreshService: TokenRefreshService + private let authRequestInterceptor: AuthRequestInterceptor + + // MARK: - Part + let partAPI: PartAPI + let partRepository: PartRepository + let getCategoryUseCase: GetCategoryUseCase + let getGroupUseCase: GetGroupUseCase + let getPartUseCase: GetPartUseCase + let searchPartsUseCase: SearchPartsUseCase + + // 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 + + // MARK: - Order + let orderAPI: OrderAPI + let orderRepository: OrderRepository + let getOrderUseCase: GetOrderUseCase + let createOrderUseCase: CreateOrderUseCase + let getOrderDetailUseCase: GetOrderDetailUseCase + let completeOrderUseCase: CompleteOrderUseCase + let receiveOrderUseCase: ReceiveOrderUseCase + let cancelOrderUseCase: CancelOrderUseCase + + // MARK: - Dashboard + let dashboardAPI: DashboardAPI + let dashboardRepository: DashboardRepository + let getDashboardUseCase: GetDashboardUseCase + let getWeeklySummaryUseCase: GetWeeklySummaryUseCase + + init() { + // Global Message Handler + globalMessageHandler = GlobalMessageHandler.shared + + // Auth Preferences + authPreferences = AuthPreferences() + + // Network Auth Services + tokenRefreshService = TokenRefreshService(authPreferences: authPreferences) + authRequestInterceptor = AuthRequestInterceptor( + authPreferences: authPreferences, + tokenRefreshService: tokenRefreshService + ) + + // Core Network + networkManager = NetworkManager(authRequestInterceptor: authRequestInterceptor) + + // Auth + authAPI = AuthAPI(networkManager: networkManager) + authRepository = AuthRepositoryImpl( + api: authAPI, + preferences: authPreferences + ) + loginUseCase = LoginUseCase(repository: authRepository) + signUpUseCase = SignUpUseCase(repository: authRepository) + checkLoginStateUseCase = CheckLoginStateUseCase(repository: authRepository) + signOutUseCase = SignOutUseCase(repository: authRepository) + clearTokensUseCase = ClearTokensUseCase(repository: authRepository) + getVendorUseCase = GetVendorUseCase(repository: authRepository) + + // Auth ViewModel + authViewModel = AuthViewModel( + checkLoginStateUseCase: checkLoginStateUseCase, + signOutUseCase: signOutUseCase, + clearTokensUseCase: clearTokensUseCase + ) + + // Part + partAPI = PartAPI(networkManager: networkManager) + partRepository = PartRepositoryImpl(api: partAPI, preferences: authPreferences) + getCategoryUseCase = GetCategoryUseCase(repository: partRepository) + getGroupUseCase = GetGroupUseCase(repository: partRepository) + getPartUseCase = GetPartUseCase(repository: partRepository) + searchPartsUseCase = SearchPartsUseCase(repository: partRepository) + + // Outbound + outboundAPI = OutboundAPI(networkManager: networkManager) + outboundRepository = OutboundRepositoryImpl(api: outboundAPI, preferences: authPreferences) + 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, preferences: authPreferences) + getCartUseCase = GetCartUseCase(repository: cartRepository) + addCartUseCase = AddCartUseCase(repository: cartRepository) + deleteCartUseCase = DeleteCartUseCase(repository: cartRepository) + deleteAllCartUseCase = DeleteAllCartUseCase(repository: cartRepository) + updateCartQuantityUseCase = UpdateCartQuantityUseCase(repository: cartRepository) + + // Order + orderAPI = OrderAPI(networkManager: networkManager) + orderRepository = OrderRepositoryImpl(api: orderAPI, preferences: authPreferences) + getOrderUseCase = GetOrderUseCase(repository: orderRepository) + createOrderUseCase = CreateOrderUseCase(repository: orderRepository) + getOrderDetailUseCase = GetOrderDetailUseCase(repository: orderRepository) + completeOrderUseCase = CompleteOrderUseCase(repository: orderRepository) + receiveOrderUseCase = ReceiveOrderUseCase(repository: orderRepository) + cancelOrderUseCase = CancelOrderUseCase(repository: orderRepository) + + // Dashboard + dashboardAPI = DashboardAPI(networkManager: networkManager) + dashboardRepository = DashboardRepositoryImpl(api: dashboardAPI, authPreferences: authPreferences) + getDashboardUseCase = GetDashboardUseCase(repository: dashboardRepository) + getWeeklySummaryUseCase = GetWeeklySummaryUseCase(repository: dashboardRepository) + } + + // MARK: - ViewModel Factories + + func makeLoginViewModel() -> LoginViewModel { + return LoginViewModel(loginUseCase: loginUseCase) + } + + func makeSignUpViewModel() -> SignUpViewModel { + return SignUpViewModel(signUpUseCase: signUpUseCase, getVendorUseCase: getVendorUseCase) + } + + func makePartViewModel() -> PartViewModel { + return PartViewModel( + getCategoryUseCase: getCategoryUseCase, + getGroupUseCase: getGroupUseCase + ) + } + + func makePartListViewModel(groupId: Int) -> PartListViewModel { + return PartListViewModel( + getPartUseCase: getPartUseCase, groupId: groupId + ) + } + + func makePartDetailViewModel() -> PartDetailViewModel { + return PartDetailViewModel( + addOutboundUseCase: addOutboundUseCase, + addCartUseCase: addCartUseCase, + globalMessageHandler: globalMessageHandler + ) + } + + func makeSearchViewModel() -> SearchViewModel { + let partDetailViewModel = makePartDetailViewModel() + return SearchViewModel(searchPartsUseCase: searchPartsUseCase, partDetailViewModel: partDetailViewModel) + } + + 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, + createOrderUseCase: createOrderUseCase, + globalMessageHandler: globalMessageHandler + ) + } + + func makeOrderListViewModel() -> OrderListViewModel { + return OrderListViewModel( + getOrderUseCase: getOrderUseCase, + globalMessageHandler: globalMessageHandler + ) + } + + func makeOrderDetailViewModel(orderId: Int) -> OrderDetailViewModel { + return OrderDetailViewModel( + getOrderDetailUseCase: getOrderDetailUseCase, + cancelOrderUseCase: cancelOrderUseCase, + completeOrderUseCase: completeOrderUseCase, + receiveOrderUseCase: receiveOrderUseCase, + globalMessageHandler: globalMessageHandler, + orderId: orderId + ) + } + + func makeSettingViewModel() -> SettingViewModel { + return SettingViewModel( + authPreferences: authPreferences, + signOutUseCase: signOutUseCase, + globalMessageHandler: globalMessageHandler + ) + } + + func makeDashboardViewModel() -> DashboardViewModel { + return DashboardViewModel( + getOrderUseCase: getOrderUseCase, + getDashboardUseCase: getDashboardUseCase, + getWeeklySummaryUseCase: getWeeklySummaryUseCase, + messageHandler: globalMessageHandler + ) + } +} + diff --git a/SampoomManagement/Core/DI/CoreDIModule.swift b/SampoomManagement/Core/DI/CoreDIModule.swift deleted file mode 100644 index 898877c..0000000 --- a/SampoomManagement/Core/DI/CoreDIModule.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// CoreDIModule.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class CoreDIModule: Assembly { - func assemble(container: Container) { - // MARK: - Core Layer Dependencies - - // NetworkManager 등록 - container.register(NetworkManager.self) { _ in - NetworkManager() - }.inObjectScope(.container) - } -} diff --git a/SampoomManagement/Core/DI/DIContainer.swift b/SampoomManagement/Core/DI/DIContainer.swift deleted file mode 100644 index 8813450..0000000 --- a/SampoomManagement/Core/DI/DIContainer.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// DIContainer.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class DIContainer { - static let shared = DIContainer() - - private let container: Container - private let assembler: Assembler - - private init() { - container = Container() - assembler = Assembler([ - CoreDIModule(), // Core 레벨 의존성 - PartDIModule() // Part Feature 의존성 - ], container: container) - } - - func resolve(_ type: T.Type) -> T? { - return container.resolve(type) - } - - func resolve(_ type: T.Type, name: String) -> T? { - return container.resolve(type, name: name) - } -} diff --git a/SampoomManagement/Core/Network/APIResponse.swift b/SampoomManagement/Core/Network/APIResponse.swift index 907cf15..9590b17 100644 --- a/SampoomManagement/Core/Network/APIResponse.swift +++ b/SampoomManagement/Core/Network/APIResponse.swift @@ -5,11 +5,20 @@ // Created by 채상윤 on 9/29/25. // -import Foundation +@preconcurrency import Foundation struct APIResponse: Codable { let status: Int let success: Bool let message: String - let data: T + let data: T? +} + +struct EmptyResponse: Codable { +} + +/// API 에러 응답 (안드로이드와 동일한 구조) +struct ApiErrorResponse: Codable { + let code: Int? + let message: String? } diff --git a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift new file mode 100644 index 0000000..1476706 --- /dev/null +++ b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift @@ -0,0 +1,81 @@ +// +// AuthRequestInterceptor.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import Alamofire + +// 비동기-세이프 토큰 갱신 조정자 +actor RefreshCoordinator { + private var inFlight: Task? + + func refresh(using service: TokenRefreshService) async throws -> User { + if let t = inFlight { + return try await t.value + } + let t = Task { + try await service.refreshToken() + } + inFlight = t + defer { inFlight = nil } + return try await t.value + } +} + +final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable { + private let authPreferences: AuthPreferences + private let tokenRefreshService: TokenRefreshService + private let refreshCoordinator = RefreshCoordinator() + + init(authPreferences: AuthPreferences, tokenRefreshService: TokenRefreshService) { + self.authPreferences = authPreferences + self.tokenRefreshService = tokenRefreshService + } + + // 요청에 Authorization 헤더 추가 + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + var adaptedRequest = urlRequest + + do { + if let accessToken = try authPreferences.getAccessToken() { + adaptedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } else { + adaptedRequest.setValue(nil, forHTTPHeaderField: "Authorization") + } + } catch { + print("AuthRequestInterceptor - 토큰 조회 실패: \(error)") + } + + completion(.success(adaptedRequest)) + } + + // 401 응답 시 토큰 재발급 및 재시도 + func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { + // 재시도 한도 (예: 1회) + if request.retryCount >= 1 { + completion(.doNotRetry) + return + } + + guard let response = request.task?.response as? HTTPURLResponse, + response.statusCode == 401 else { + completion(.doNotRetry) + return + } + + Task { + do { + _ = try await refreshCoordinator.refresh(using: tokenRefreshService) + completion(.retryWithDelay(0.1)) + } catch { + print("AuthRequestInterceptor - 토큰 재발급 실패: \(error)") + // 토큰 재발급 실패 시 로그아웃 처리 + await authPreferences.clear() + completion(.doNotRetry) + } + } + } +} diff --git a/SampoomManagement/Core/Network/NetworkError.swift b/SampoomManagement/Core/Network/NetworkError.swift index 4333563..6f013e0 100644 --- a/SampoomManagement/Core/Network/NetworkError.swift +++ b/SampoomManagement/Core/Network/NetworkError.swift @@ -12,20 +12,74 @@ enum NetworkError: Error, LocalizedError { case decodingError(Error) case invalidURL case noData - case serverError(Int) + case serverError(Int, message: String?) + case invalidParameters + case unauthorized var errorDescription: String? { switch self { case .networkError(let error): return "네트워크 오류: \(error.localizedDescription)" case .decodingError(let error): + // 디코딩 에러의 실제 메시지 추출 시도 + if let decodingError = error as? DecodingError { + return decodingErrorMessage(decodingError) + } return "데이터 파싱 오류: \(error.localizedDescription)" case .invalidURL: return "잘못된 URL" case .noData: return "데이터가 없습니다" - case .serverError(let code): + case .serverError(let code, let message): + if let message = message, !message.isEmpty { + return message + } return "서버 오류: \(code)" + case .invalidParameters: + return "잘못된 매개변수입니다" + case .unauthorized: + return "인증이 필요합니다" + } + } + + private func decodingErrorMessage(_ error: DecodingError) -> String { + switch error { + case .dataCorrupted(let context): + return "데이터 형식 오류: \(context.debugDescription)" + case .keyNotFound(let key, _): + return "필수 데이터 누락: \(key.stringValue)" + case .typeMismatch(let type, _): + return "데이터 형식 불일치: \(type)" + case .valueNotFound(let type, _): + return "필수 값 누락: \(type)" + @unknown default: + return "데이터 파싱 오류" + } + } +} + +enum AuthError: Error, LocalizedError { + case tokenSaveFailed(Error) + case invalidCredentials + case networkError(Error) + case invalidResponse + case tokenRefreshFailed + case unauthorized + + var errorDescription: String? { + switch self { + case .tokenSaveFailed(let error): + return "토큰 저장 실패: \(error.localizedDescription)" + case .invalidCredentials: + return "잘못된 인증 정보입니다" + case .networkError(let error): + return "네트워크 오류: \(error.localizedDescription)" + case .invalidResponse: + return "잘못된 응답입니다" + case .tokenRefreshFailed: + return "토큰 재발급에 실패했습니다" + case .unauthorized: + return "인증이 필요합니다" } } } diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index ee13375..bfe7790 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -5,47 +5,179 @@ // Created by 채상윤 on 9/29/25. // -import Foundation +@preconcurrency import Foundation import Alamofire class NetworkManager { - static let shared = NetworkManager() + private let baseURL = "https://sampoom.store/api/" + private let session: Session - private let baseURL = "http://localhost:8080/api/" + init(authRequestInterceptor: AuthRequestInterceptor) { + // Alamofire Session 설정 with interceptor + let configuration = URLSessionConfiguration.default + self.session = Session(configuration: configuration, interceptor: authRequestInterceptor) + } - init() {} + // 디코딩을 main actor 컨텍스트에서 수행 + // Swift 6 strict concurrency: 타입이 actor와 격리되지 않았음을 보장하기 위해 + // @MainActor 함수에서 직접 디코딩 + @MainActor private func decodeApiErrorResponse(from data: Data) -> ApiErrorResponse? { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try? decoder.decode(ApiErrorResponse.self, from: data) + } + + @MainActor private func decodeApiResponse(_ type: T.Type, from data: Data) throws -> APIResponse { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try decoder.decode(APIResponse.self, from: data) + } + + @MainActor private func decodeEmptyApiResponse(from data: Data) -> APIResponse? { + let decoder = JSONDecoder() + // @MainActor 함수 내에서는 Codable 디코딩이 actor 격리 문제 없이 수행됨 + return try? decoder.decode(APIResponse.self, from: data) + } func request( endpoint: String, method: HTTPMethod = .get, 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 = session.request( + url, + method: method, + parameters: parameters, + encoding: method == .get ? URLEncoding.default : 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 + // HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도 + if let httpResponse = response.response, + httpResponse.statusCode >= 400, + let data = response.data { + + Task { @MainActor in + // 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일) + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let errorCode = errorResponse.code ?? httpResponse.statusCode + continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message)) + return + } + + // 2. APIResponse 형식으로 파싱 시도 (기존 방식) + if let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + // 3. 파싱 실패 시 기본 에러 + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } + return + } + + switch response.result { + case .success(let data): + Task { @MainActor in + do { + print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") + let apiResponse = try self.decodeApiResponse(T.self, from: data) + print("NetworkManager - Decoded response: \(apiResponse)") + continuation.resume(returning: apiResponse) + } catch { + print("NetworkManager - Decoding error: \(error)") + continuation.resume(throwing: NetworkError.decodingError(error)) + } + } + case .failure(let error): + print("NetworkManager - Network error: \(error)") + continuation.resume(throwing: NetworkError.networkError(error)) } } - case .failure(let error): - Task { @MainActor in - completion(.failure(.networkError(error))) + } + }, onCancel: { + }) + } + + func request( + endpoint: String, + method: HTTPMethod = .get, + body: E? = nil, + responseType: T.Type + ) async throws -> APIResponse { + let url = baseURL + endpoint + + return try await withTaskCancellationHandler(operation: { + try await withCheckedThrowingContinuation { continuation in + let dataRequest: DataRequest + if let body = body { + dataRequest = session.request( + url, + method: method, + parameters: body, + encoder: JSONParameterEncoder.default + ) + } else { + dataRequest = session.request( + url, + method: method, + encoding: method == .get ? URLEncoding.default : JSONEncoding.default + ) + } + + dataRequest.responseData { response in + // HTTP 상태 코드가 에러 범위(4xx, 5xx)인 경우 응답 body를 파싱 시도 + if let httpResponse = response.response, + httpResponse.statusCode >= 400, + let data = response.data { + + Task { @MainActor in + // 1. ApiErrorResponse 형식으로 파싱 시도 (안드로이드와 동일) + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let errorCode = errorResponse.code ?? httpResponse.statusCode + continuation.resume(throwing: NetworkError.serverError(errorCode, message: errorResponse.message)) + return + } + + // 2. APIResponse 형식으로 파싱 시도 (기존 방식) + if let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + // 3. 파싱 실패 시 기본 에러 + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } + return + } + + switch response.result { + case .success(let data): + Task { @MainActor in + do { + print("NetworkManager - Raw response data: \(String(data: data, encoding: .utf8) ?? "Unable to decode")") + let apiResponse = try self.decodeApiResponse(T.self, from: data) + print("NetworkManager - Decoded response: \(apiResponse)") + continuation.resume(returning: apiResponse) + } catch { + print("NetworkManager - Decoding error: \(error)") + continuation.resume(throwing: NetworkError.decodingError(error)) + } + } + case .failure(let error): + print("NetworkManager - Network error: \(error)") + continuation.resume(throwing: NetworkError.networkError(error)) + } } } - } + }, onCancel: { + }) } } + diff --git a/SampoomManagement/Core/Network/TokenRefreshService.swift b/SampoomManagement/Core/Network/TokenRefreshService.swift new file mode 100644 index 0000000..27735ea --- /dev/null +++ b/SampoomManagement/Core/Network/TokenRefreshService.swift @@ -0,0 +1,70 @@ +// +// TokenRefreshService.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class TokenRefreshService { + private let authPreferences: AuthPreferences + + init(authPreferences: AuthPreferences) { + self.authPreferences = authPreferences + } + + func refreshToken() async throws -> User { + guard let refreshToken = try authPreferences.getRefreshToken() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 URLSession 인스턴스 생성 (인터셉터 없이) + let session = URLSession.shared + let url = URL(string: "https://sampoom.store/api/auth/refresh")! + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = RefreshRequestDTO(refreshToken: refreshToken) + request.httpBody = try JSONEncoder().encode(requestBody) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw AuthError.tokenRefreshFailed + } + + let apiResponse = try JSONDecoder().decode(APIResponse.self, from: data) + guard let dto = apiResponse.data else { + throw AuthError.invalidResponse + } + + // 기존 사용자 정보 조회 + guard let existingUser = try authPreferences.getStoredUser() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 토큰 정보로 사용자 정보 업데이트 + let updatedUser = User( + id: existingUser.id, + name: existingUser.name, + email: existingUser.email, + role: existingUser.role, + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + expiresIn: dto.expiresIn, + position: existingUser.position, + workspace: existingUser.workspace, + branch: existingUser.branch, + agencyId: existingUser.agencyId, + startedAt: existingUser.startedAt, + endedAt: existingUser.endedAt + ) + + try authPreferences.saveUser(updatedUser) + return updatedUser + } +} diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 874dd17..b2b7615 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -14,10 +14,26 @@ struct StringResources { static let title = "SampoomManagement" } + // MARK: - Dashboard + struct Dashboard { + static let greetingPrefix = "안녕하세요, " + static let greetingSuffix = " 님" + static let intro = "오늘도 효율적인 재고 관리를 시작해보세요." + static let employee = "직원 관리" + static let partsOnHand = "보유 부품" + static let partsInProgress = "진행중 부품" + static let shortageOfParts = "부족 부품" + static let orderAmount = "주문 금액" + static let recentOrdersTitle = "최근 주문" + static let weeklySummaryTitle = "이번 주 요약" + static let weeklySummaryInStock = "입고 부품" + static let weeklySummaryOutStock = "출고 부품" + } + // MARK: - Tabs struct Tabs { static let dashboard = "대시보드" - static let delivery = "출고목록" + static let outbound = "출고목록" static let cart = "장바구니" static let orders = "주문관리" static let parts = "부품조회" @@ -64,10 +80,161 @@ struct StringResources { // MARK: - Common struct Common { static let ok = "확인" + static let confirm = "확인" static let cancel = "취소" static let save = "저장" static let delete = "삭제" static let edit = "편집" static let done = "완료" + static let error = "오류" + static let retry = "다시 시도" + static let loadMore = "더 보기" + static let close = "닫기" + static let detail = "상세 보기" + static let EA = "EA" + static let slash = "-" + } + + // MARK: - Search + struct SearchParts { + static let title = "검색" + static let placeholder = "부품 검색" + static let emptyMessage = "검색 결과가 없습니다" + static let loadingMessage = "검색 중..." + } + + // 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 = "수량" + static let selectCategory = "카테고리 선택" + static let selectCategoryPrompt = "카테고리를 선택해주세요" + static let selectGroup = "그룹 선택" + static let emptyPart = "부품 목록이 없습니다." + } + + // MARK: - Order + struct Order { + static let title = "주문관리" + static let emptyList = "주문관리 목록이 없습니다." + static let detailTitle = "주문정보" + static let detailOrderNumber = "주문번호" + static let detailOrderDate = "주문일자" + static let detailOrderAgency = "대리점" + static let detailOrderStatus = "주문상태" + static let detailOrderItemsTitle = "주문상품" + static let detailOrderCancel = "주문취소" + static let detailDialogOrderCancel = "주문 취소처리하시겠습니까?" + static let detailToastOrderCancel = "주문 취소처리되었습니다" + static let detailOrderReceive = "입고처리" + static let detailDialogOrderReceive = "입고 처리하시겠습니까?" + static let detailToastOrderReceive = "입고 처리되었습니다" + static let detailTotalAmount = "총 가격" + + // Order Status + static let statusPending = "대기중" + static let statusConfirmed = "주문확인" + static let statusShipping = "배송중" + static let statusDelayed = "배송지연" + static let statusProducing = "생산중" + static let statusArrived = "배송완료" + static let statusCompleted = "입고완료" + static let statusCanceled = "주문취소" + } + + // MARK: - Setting + struct Setting { + static let title = "설정" + static let editProfile = "프로필 수정" + static let logout = "로그아웃" + static let dialogLogout = "로그아웃 하시겠습니까?" + } + + // MARK: - Auth + struct Auth { + // Login + static let emailLabel = "이메일" + static let passwordLabel = "비밀번호" + static let emailPlaceholder = "이메일 입력" + static let passwordPlaceholder = "비밀번호 입력" + static let loginButton = "로그인" + static let loginButtonLoading = "로그인 중..." + static let needAccount = "계정이 없으신가요?" + static let signUpLink = "회원가입" + static let signUpDo = "하기" + static let logoutButton = "로그아웃" + + // SignUp + static let nameLabel = "이름" + static let branchLabel = "지점" + static let positionLabel = "직급" + static let passwordCheckLabel = "비밀번호 확인" + static let namePlaceholder = "이름 입력" + static let branchPlaceholder = "지점 입력" + static let positionPlaceholder = "직급 입력" + static let passwordCheckPlaceholder = "비밀번호 확인 입력" + static let signUpButton = "회원가입" + static let signUpButtonLoading = "회원가입 중..." + static let back = "뒤로" + + // Validation Messages + static func fieldRequired(_ field: String) -> String { + return "\(field)을(를) 입력해주세요" + } + static let emailRequired = "이메일을 입력해주세요" + static let emailInvalid = "올바른 이메일 형식이 아닙니다" + static let passwordRequired = "비밀번호를 입력해주세요" + static let passwordTooShort = "비밀번호는 최소 8자 이상이어야 합니다" + static let passwordMaxLength = "비밀번호는 최대 30자까지 가능합니다" + static let passwordComplexity = "영문, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다" + static let passwordInvalid = "비밀번호는 영문과 숫자를 포함해야 합니다." + static let passwordCheckRequired = "비밀번호 확인을 입력해주세요" + static let passwordCheckMismatch = "비밀번호가 일치하지 않습니다" } } 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 cb1f91a..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,38 +79,50 @@ 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) } Text(title) - .font(size.font) + .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) } } .frame(height: size.height) .frame(maxWidth: .infinity) + .padding(4) .foregroundColor(buttonTextColor) .background(buttonBackgroundColor) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 16) .stroke(buttonBorderColor, lineWidth: borderWidth) ) - .cornerRadius(8) + .cornerRadius(16) } .disabled(!isEnabled) - .opacity(isEnabled ? 1.0 : 0.6) .animation(.easeInOut(duration: 0.2), value: isEnabled) } // MARK: - Button Styling private var buttonBackgroundColor: Color { if !isEnabled { - return .gray + return .disable } if let customColor = backgroundColor { @@ -116,7 +131,7 @@ struct CommonButton: View { switch type { case .filled: - return Color(red: 0.5, green: 0.2, blue: 0.8) // 기본 보라색 + return Color(.accent) // 기본 보라색 case .outlined: return .clear } @@ -124,7 +139,7 @@ struct CommonButton: View { private var buttonTextColor: Color { if !isEnabled { - return .white + return .textSecondary } if let customColor = textColor { @@ -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 1225005..3ac4c36 100644 --- a/SampoomManagement/Core/UI/Components/CommonTextField.swift +++ b/SampoomManagement/Core/UI/Components/CommonTextField.swift @@ -50,97 +50,111 @@ enum TextFieldSize { struct CommonTextField: View { @Environment(\.colorScheme) private var colorScheme @State private var isPasswordVisible = false - @State private var text = "" + @FocusState private var isFocused: Bool + @Binding var value: String let placeholder: String let type: TextFieldType let size: TextFieldSize - let textColor: Color? - let backgroundColor: Color? - let borderColor: Color? + let isError: Bool + let errorMessage: String? let onTextChange: (String) -> Void + let submitLabel: SubmitLabel + let onSubmit: () -> Void init( + value: Binding, placeholder: String, type: TextFieldType = .text, size: TextFieldSize = .medium, - textColor: Color? = nil, - backgroundColor: Color? = nil, - borderColor: Color? = nil, - onTextChange: @escaping (String) -> Void = { _ in } + isError: Bool = false, + errorMessage: String? = nil, + onTextChange: @escaping (String) -> Void = { _ in }, + submitLabel: SubmitLabel = .next, + onSubmit: @escaping () -> Void = {} ) { + self._value = value self.placeholder = placeholder self.type = type self.size = size - self.textColor = textColor - self.backgroundColor = backgroundColor - self.borderColor = borderColor + self.isError = isError + self.errorMessage = errorMessage self.onTextChange = onTextChange + self.submitLabel = submitLabel + self.onSubmit = onSubmit } var body: some View { - HStack { - // Text Field - Group { - if type == .password && !isPasswordVisible { - SecureField(placeholder, text: $text) - .textFieldStyle(PlainTextFieldStyle()) - } else { - TextField(placeholder, text: $text) - .keyboardType(keyboardType) - .textInputAutocapitalization(autocapitalization) - .disableAutocorrection(disableAutocorrection) - .textFieldStyle(PlainTextFieldStyle()) + VStack(alignment: .leading, spacing: 4) { + HStack { + // Text Field + Group { + if type == .password && !isPasswordVisible { + SecureField(placeholder, text: $value) + .submitLabel(submitLabel) + .onSubmit { onSubmit() } + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + } else { + TextField(placeholder, text: $value) + .keyboardType(keyboardType) + .textInputAutocapitalization(autocapitalization) + .disableAutocorrection(disableAutocorrection) + .submitLabel(submitLabel) + .onSubmit { onSubmit() } + .textFieldStyle(PlainTextFieldStyle()) + .focused($isFocused) + } + } + .font(.gmarketBody) + .foregroundColor(buttonTextColor) + + // Password Toggle Button (inside TextField) + if type == .password { + Button(action: { + isPasswordVisible.toggle() + }) { + Image(systemName: isPasswordVisible ? "eye.slash" : "eye") + .foregroundColor(iconColor) + .font(.system(size: 16, weight: .medium)) + } + .padding(.trailing, 8) } } - .font(size.font) - .foregroundColor(buttonTextColor) + .padding(size.padding) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(effectiveBorderColor, lineWidth: isFocused ? 1.5 : 1) + ) - // Password Toggle Button (inside TextField) - if type == .password { - Button(action: { - isPasswordVisible.toggle() - }) { - Image(systemName: isPasswordVisible ? "eye.slash" : "eye") - .foregroundColor(iconColor) - .font(.system(size: 16, weight: .medium)) - } - .padding(.trailing, 8) + // Error Message + if isError, let errorMessage = errorMessage { + Text(errorMessage) + .font(.gmarketBody) + .foregroundColor(.red) + .padding(.leading, 4) } } - .padding(size.padding) - .background(buttonBackgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(buttonBorderColor, lineWidth: 1) - ) - .onChange(of: text) { oldValue, newValue in + .onChange(of: value) { _, newValue in onTextChange(newValue) } } // MARK: - Computed Properties private var buttonTextColor: Color { - if let textColor = textColor { - return textColor - } - // 다크모드 고려한 기본 색상 switch colorScheme { case .dark: - return text.isEmpty ? .gray : .white + return value.isEmpty ? .gray : .white case .light: - return text.isEmpty ? .gray : .black + return value.isEmpty ? .gray : .black @unknown default: - return text.isEmpty ? .gray : .primary + return value.isEmpty ? .gray : .primary } } private var buttonBackgroundColor: Color { - if let backgroundColor = backgroundColor { - return backgroundColor - } - // 다크모드 고려한 기본 배경색 switch colorScheme { case .dark: @@ -153,19 +167,25 @@ struct CommonTextField: View { } private var buttonBorderColor: Color { - if let borderColor = borderColor { - return borderColor - } - // 다크모드 고려한 기본 테두리색 switch colorScheme { case .dark: - return .gray.opacity(0.3) + return .gray.opacity(0.4) case .light: - return .gray.opacity(0.3) + return .gray.opacity(0.4) @unknown default: - return .gray.opacity(0.3) + return .gray.opacity(0.4) + } + } + + private var effectiveBorderColor: Color { + if isError { + return .red } + if isFocused { + return .accentColor + } + return buttonBorderColor } private var iconColor: Color { @@ -213,53 +233,4 @@ struct CommonTextField: View { } } -// MARK: - Preview -#Preview { - VStack(spacing: 16) { - // Email Input (Placeholder) - CommonTextField( - placeholder: "이메일 입력", - type: .email - ) { text in - print("Email: \(text)") - } - - // Email Input (Filled) - CommonTextField( - placeholder: "이메일 입력", - type: .email - ) { text in - print("Email: \(text)") - } - - // Password Input (Placeholder) - CommonTextField( - placeholder: "비밀번호 입력", - type: .password - ) { text in - print("Password: \(text)") - } - - // Password Input (Filled) - CommonTextField( - placeholder: "비밀번호 입력", - type: .password - ) { text in - print("Password: \(text)") - } - - // Custom Colors - CommonTextField( - placeholder: "커스텀 색상", - type: .text, - textColor: .blue, - backgroundColor: .yellow.opacity(0.1), - borderColor: .blue - ) { text in - print("Custom: \(text)") - } - } - .padding() - .background(Color(.systemBackground)) -} diff --git a/SampoomManagement/Core/UI/Components/EmptyView.swift b/SampoomManagement/Core/UI/Components/EmptyView.swift index 95e4a6d..16c7f9a 100644 --- a/SampoomManagement/Core/UI/Components/EmptyView.swift +++ b/SampoomManagement/Core/UI/Components/EmptyView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct EmptyStateView: View { +struct EmptyView: View { let icon: String let title: String let message: String? @@ -46,11 +46,3 @@ struct EmptyStateView: View { } } } - -#Preview { - EmptyStateView( - icon: "tray", - title: "인벤토리가 비어있습니다", - message: "새로운 부품을 추가해보세요" - ) -} diff --git a/SampoomManagement/Core/UI/Components/ErrorView.swift b/SampoomManagement/Core/UI/Components/ErrorView.swift index 898e1b1..fb9d949 100644 --- a/SampoomManagement/Core/UI/Components/ErrorView.swift +++ b/SampoomManagement/Core/UI/Components/ErrorView.swift @@ -15,22 +15,15 @@ struct ErrorView: View { VStack(spacing: 16) { Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundColor(.red) - Text("오류가 발생했습니다") - .font(.headline) + .font(.gmarketHeadline) .foregroundColor(.red) - Text(error) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - Button("다시 시도") { + Button { onRetry() + } label: { + Text("다시 시도") + .font(.gmarketCaption) } .buttonStyle(.borderedProminent) @@ -39,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/Core/UI/Components/OrderItem.swift b/SampoomManagement/Core/UI/Components/OrderItem.swift new file mode 100644 index 0000000..2c8b1d7 --- /dev/null +++ b/SampoomManagement/Core/UI/Components/OrderItem.swift @@ -0,0 +1,44 @@ +// +// OrderItem.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct OrderItem: View { + let order: Order + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(OrderFormatter.buildOrderTitle(order)) + .font(.gmarketBody) + .lineLimit(1) + .foregroundColor(.text) + Text(order.agencyName ?? "-") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + Spacer(minLength: 12) + VStack(alignment: .trailing, spacing: 6) { + Text(order.createdAt.map { DateFormatterUtil.formatDate($0) } ?? "-") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + StatusChip(status: order.status) + Text(formatWon(order.totalCost)) + .font(.gmarketBody) + .foregroundColor(.text) + } + } + .padding(16) + .background(Color.backgroundCard) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} + + diff --git a/SampoomManagement/Core/UI/Components/StatusChip.swift b/SampoomManagement/Core/UI/Components/StatusChip.swift new file mode 100644 index 0000000..7883a48 --- /dev/null +++ b/SampoomManagement/Core/UI/Components/StatusChip.swift @@ -0,0 +1,59 @@ +// +// StatusChip.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI +import Foundation + +/// 주문 상태를 표시하는 칩 컴포넌트 +struct StatusChip: View { + let status: OrderStatus + + var body: some View { + let (text, color) = statusDisplayInfo + + Text(text) + .font(.caption) + .foregroundColor(color) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(color.opacity(0.2)) + .cornerRadius(16) + } + + private var statusDisplayInfo: (text: String, color: Color) { + switch status { + case .pending: + return (StringResources.Order.statusPending, Color(.waitYellow)) + case .confirmed: + return (StringResources.Order.statusConfirmed, Color(.waitYellow)) + case .shipping: + return (StringResources.Order.statusShipping, Color(.waitYellow)) + case .delayed: + return (StringResources.Order.statusDelayed, Color(.waitYellow)) + case .producing: + return (StringResources.Order.statusProducing, Color(.waitYellow)) + case .arrived: + return (StringResources.Order.statusArrived, Color(.waitYellow)) + case .completed: + return (StringResources.Order.statusCompleted, Color(.successGreen)) + case .canceled: + return (StringResources.Order.statusCanceled, Color(.failRed)) + } + } +} + +#Preview { + VStack(spacing: 16) { + StatusChip(status: .pending) + StatusChip(status: .confirmed) + StatusChip(status: .shipping) + StatusChip(status: .arrived) + StatusChip(status: .completed) + StatusChip(status: .canceled) + } + .padding() +} diff --git a/SampoomManagement/Core/UI/Components/ToastContainer.swift b/SampoomManagement/Core/UI/Components/ToastContainer.swift new file mode 100644 index 0000000..30b58cf --- /dev/null +++ b/SampoomManagement/Core/UI/Components/ToastContainer.swift @@ -0,0 +1,34 @@ +// +// ToastContainer.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import SwiftUI +import Toast +import UIKit + +/// 앱 최상단에 Toast를 표시하기 위한 컨테이너 뷰 +struct ToastContainer: View { + @ObservedObject var globalMessageHandler: GlobalMessageHandler + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onChange(of: globalMessageHandler.message) { _, message in + if let message = message, !message.isEmpty { + DispatchQueue.main.async { + showToastOnTopWindow(message) + } + } + } + } + + private func showToastOnTopWindow(_ message: String) { + // Toast 라이브러리는 기본적으로 앱의 최상단 window에 표시됩니다 + // RootView에서 호출되므로 자동으로 최상단에 표시됨 + Toast.text(message).show() + } +} + diff --git a/SampoomManagement/Core/UI/Extensions/Font+Extension.swift b/SampoomManagement/Core/UI/Extensions/Font+Extension.swift new file mode 100644 index 0000000..0562e2b --- /dev/null +++ b/SampoomManagement/Core/UI/Extensions/Font+Extension.swift @@ -0,0 +1,98 @@ +// +// Font+Extension.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI + +extension Font { + // MARK: - GmarketSans Font + + enum GmarketSansWeight { + case light + case medium + case bold + + var name: String { + switch self { + case .light: return "GmarketSansLight" + case .medium: return "GmarketSansMedium" + case .bold: return "GmarketSansBold" + } + } + } + + /// GmarketSans 커스텀 폰트 + static func gmarketSans(size: CGFloat, weight: GmarketSansWeight = .medium) -> Font { + return .custom(weight.name, size: size) + } + + // MARK: - GmarketSans Semantic Fonts + + /// 큰 타이틀 (40pt, Medium) - 매우 큰 제목 + static var gmarketLargeTitle: Font { + return .gmarketSans(size: 40, weight: .medium) + } + + /// 타이틀 (32pt, Medium) - 화면 제목 + static var gmarketTitle: Font { + return .gmarketSans(size: 32, weight: .medium) + } + + /// 타이틀 2 (24pt, Medium) - 섹션 제목 + static var gmarketTitle2: Font { + return .gmarketSans(size: 24, weight: .medium) + } + + /// 타이틀 3 (20pt, Medium) - 작은 제목 + static var gmarketTitle3: Font { + return .gmarketSans(size: 20, weight: .medium) + } + + /// 헤드라인 (18pt, Medium) - 강조 텍스트 + static var gmarketHeadline: Font { + return .gmarketSans(size: 18, weight: .medium) + } + + /// 본문 (16pt, Medium) - 기본 본문 + static var gmarketBody: Font { + return .gmarketSans(size: 16, weight: .medium) + } + + /// 서브헤드 (14pt, Medium) - 작은 본문 + static var gmarketSubheadline: Font { + return .gmarketSans(size: 14, weight: .medium) + } + + /// 캡션 (13pt, Medium) - 설명 텍스트 + static var gmarketCaption: Font { + return .gmarketSans(size: 13, weight: .medium) + } + + /// 작은 캡션 (12pt, Medium) - 매우 작은 텍스트 + static var gmarketCaption2: Font { + return .gmarketSans(size: 12, weight: .medium) + } + + // MARK: - Bold Variants (필요시 사용) + + /// 타이틀 Bold (32pt, Bold) + static var gmarketTitleBold: Font { + return .gmarketSans(size: 32, weight: .bold) + } + + /// 헤드라인 Bold (18pt, Bold) + static var gmarketHeadlineBold: Font { + return .gmarketSans(size: 18, weight: .bold) + } +} + +// MARK: - UIFont Extension (UIKit 사용 시) +extension UIFont { + static func gmarketSans(size: CGFloat, weight: Font.GmarketSansWeight) -> UIFont? { + return UIFont(name: weight.name, size: size) + } +} + diff --git a/SampoomManagement/Core/Utilities/CurrencyFormatter.swift b/SampoomManagement/Core/Utilities/CurrencyFormatter.swift new file mode 100644 index 0000000..30bf103 --- /dev/null +++ b/SampoomManagement/Core/Utilities/CurrencyFormatter.swift @@ -0,0 +1,18 @@ +// +// CurrencyFormatter.swift +// SampoomManagement +// +// Simple KRW formatter: 12,345원 +// + +import Foundation + +func formatWon(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "ko_KR") + let number = NSNumber(value: value) + return (formatter.string(from: number) ?? "\(value)") + "원" +} + + diff --git a/SampoomManagement/Core/Utilities/DateFormatterUtil.swift b/SampoomManagement/Core/Utilities/DateFormatterUtil.swift new file mode 100644 index 0000000..85e8f9f --- /dev/null +++ b/SampoomManagement/Core/Utilities/DateFormatterUtil.swift @@ -0,0 +1,51 @@ +// +// DateFormatterUtil.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +/// 날짜 포맷팅 유틸리티 +struct DateFormatterUtil { + + /// ISO 8601 날짜 문자열을 로컬 날짜 형식으로 변환 + /// - Parameter dateString: ISO 8601 형식의 날짜 문자열 + /// - Returns: 변환된 날짜 문자열 (yyyy-MM-dd) 또는 원본 문자열 (실패 시) + static func formatDate(_ dateString: String) -> String { + // ISO 8601 형식 파싱 + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime] + + // 로컬 날짜 형식 + let outputFormatter = Foundation.DateFormatter() + outputFormatter.dateFormat = "yyyy-MM-dd" + outputFormatter.locale = Locale(identifier: "ko_KR") + + guard let date = isoFormatter.date(from: dateString) else { + // ISO 8601 파싱 실패 시 다른 형식 시도 + let alternativeFormatters = [ + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + ] + + for format in alternativeFormatters { + let formatter = Foundation.DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale(identifier: "ko_KR") + + if let date = formatter.date(from: dateString) { + return outputFormatter.string(from: date) + } + } + + // 모든 파싱 실패 시 원본 반환 + return dateString + } + + return outputFormatter.string(from: date) + } +} diff --git a/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift b/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift new file mode 100644 index 0000000..6586483 --- /dev/null +++ b/SampoomManagement/Core/Utilities/GlobalMessageHandler.swift @@ -0,0 +1,32 @@ +// +// GlobalMessageHandler.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import Foundation +import SwiftUI +import Combine + +/// 전역 에러 메시지 핸들러 +class GlobalMessageHandler: ObservableObject { + @Published var message: String? + @Published var isError: Bool = false + + static let shared = GlobalMessageHandler() + + private init() {} + + func showMessage(_ message: String, isError: Bool = false) { + DispatchQueue.main.async { [weak self] in + self?.isError = isError + self?.message = message + // 메시지를 표시한 후 잠시 후 nil로 설정하여 같은 메시지도 다시 표시할 수 있도록 함 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.message = nil + } + } + } +} + diff --git a/SampoomManagement/Core/Utilities/KeyboardObserver.swift b/SampoomManagement/Core/Utilities/KeyboardObserver.swift new file mode 100644 index 0000000..48f95d2 --- /dev/null +++ b/SampoomManagement/Core/Utilities/KeyboardObserver.swift @@ -0,0 +1,38 @@ +// +// KeyboardObserver.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import SwiftUI +import Combine + +class KeyboardObserver: ObservableObject { + @Published var keyboardHeight: CGFloat = 0 + + private var cancellables = Set() + + init() { + // 키보드가 올라올 때 + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect } + .map { $0.height } + .sink { [weak self] height in + withAnimation(.easeOut(duration: 0.25)) { + self?.keyboardHeight = height + } + } + .store(in: &cancellables) + + // 키보드가 내려갈 때 + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .sink { [weak self] _ in + withAnimation(.easeOut(duration: 0.25)) { + self?.keyboardHeight = 0 + } + } + .store(in: &cancellables) + } +} + diff --git a/SampoomManagement/Core/Utilities/OrderFormatter.swift b/SampoomManagement/Core/Utilities/OrderFormatter.swift new file mode 100644 index 0000000..425322f --- /dev/null +++ b/SampoomManagement/Core/Utilities/OrderFormatter.swift @@ -0,0 +1,39 @@ +// +// OrderFormatter.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +struct OrderFormatter { + static func buildOrderTitle(_ order: Order) -> String { + // 모든 파트를 평면화 + let flattened: [(category: String, group: String, part: OrderPart)] = order.items.flatMap { category in + category.groups.flatMap { group in + group.parts.map { part in + (category: category.categoryName, group: group.groupName, part: part) + } + } + } + + // 빈 목록인 경우 + guard !flattened.isEmpty else { + return "-" + } + + let first = flattened.first! + let groupName = first.group + let part = first.part + let totalParts = flattened.count + + // 단일 아이템인 경우 + if totalParts == 1 { + return "\(groupName) - \(part.name) \(part.quantity)EA" + } else { + // 여러 아이템인 경우 + return "\(groupName) - \(part.name) \(part.quantity)EA 외 \(totalParts - 1)건" + } + } +} diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift new file mode 100644 index 0000000..75048ff --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift @@ -0,0 +1,167 @@ +// +// AuthPreferences.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthPreferences { + private let keychain = KeychainManager() + + private enum Keys { + static let accessToken = "auth.accessToken" + static let refreshToken = "auth.refreshToken" + static let userId = "auth.userId" + static let userName = "auth.userName" + static let userEmail = "auth.userEmail" + static let userRole = "auth.userRole" + static let expiresIn = "auth.expiresIn" + static let position = "auth.position" + static let workspace = "auth.workspace" + static let branch = "auth.branch" + static let agencyId = "auth.agencyId" + static let startedAt = "auth.startedAt" + static let endedAt = "auth.endedAt" + } + + func saveUser(_ user: User) throws { + do { + try keychain.save(user.accessToken, for: Keys.accessToken) + try keychain.save(user.refreshToken, for: Keys.refreshToken) + try keychain.save(String(user.id), for: Keys.userId) + try keychain.save(user.name, for: Keys.userName) + try keychain.save(user.email, for: Keys.userEmail) + try keychain.save(user.role.rawValue, for: Keys.userRole) + try keychain.save(String(user.expiresIn), for: Keys.expiresIn) + try keychain.save(user.position.rawValue, for: Keys.position) + try keychain.save(user.workspace, for: Keys.workspace) + try keychain.save(user.branch, for: Keys.branch) + try keychain.save(String(user.agencyId), for: Keys.agencyId) + try keychain.save(user.startedAt ?? "", for: Keys.startedAt) + try keychain.save(user.endedAt ?? "", for: Keys.endedAt) + } catch { + // 부분 저장 실패 시 롤백 + try? keychain.delete(Keys.accessToken) + try? keychain.delete(Keys.refreshToken) + try? keychain.delete(Keys.userId) + try? keychain.delete(Keys.userName) + try? keychain.delete(Keys.userEmail) + try? keychain.delete(Keys.userRole) + try? keychain.delete(Keys.expiresIn) + try? keychain.delete(Keys.position) + try? keychain.delete(Keys.workspace) + try? keychain.delete(Keys.branch) + try? keychain.delete(Keys.agencyId) + try? keychain.delete(Keys.startedAt) + try? keychain.delete(Keys.endedAt) + throw error + } + } + + func saveToken(accessToken: String, refreshToken: String) throws { + do { + try keychain.save(accessToken, for: Keys.accessToken) + try keychain.save(refreshToken, for: Keys.refreshToken) + } catch { + // 부분 저장 실패 시 롤백 + try? keychain.delete(Keys.accessToken) + try? keychain.delete(Keys.refreshToken) + throw error + } + } + + func getStoredUser() throws -> User? { + do { + // Require only core identifiers and tokens; allow missing new fields for migration + guard let userIdString = try keychain.get(Keys.userId), + let userId = Int(userIdString), + let userName = try keychain.get(Keys.userName), + let userRole = try keychain.get(Keys.userRole), + let accessToken = try keychain.get(Keys.accessToken), + let refreshToken = try keychain.get(Keys.refreshToken), + let expiresInString = try keychain.get(Keys.expiresIn), + let expiresIn = Int(expiresInString) else { + return nil + } + // Tolerate missing profile keys by defaulting to safe values + let positionRaw = (try? keychain.get(Keys.position)) ?? "" + let workspace = (try? keychain.get(Keys.workspace)) ?? "" + let branch = (try? keychain.get(Keys.branch)) ?? "" + let userEmail = (try? keychain.get(Keys.userEmail)) ?? "" + let agencyId = Int((try? keychain.get(Keys.agencyId)) ?? "0") ?? 0 + let startedAt = try? keychain.get(Keys.startedAt) + let endedAt = try? keychain.get(Keys.endedAt) + + return User( + id: userId, + name: userName, + email: userEmail, + role: UserRole(rawValue: userRole) ?? .user, + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + position: UserPosition(rawValue: positionRaw) ?? .staff, + workspace: workspace, + branch: branch, + agencyId: agencyId, + startedAt: startedAt?.isEmpty == false ? startedAt : nil, + endedAt: endedAt?.isEmpty == false ? endedAt : nil + ) + } catch { + print("AuthPreferences - 사용자 정보 조회 실패: \(error)") + return nil + } + } + + func getAccessToken() throws -> String? { + return try keychain.get(Keys.accessToken) + } + + func getRefreshToken() throws -> String? { + return try keychain.get(Keys.refreshToken) + } + + func hasToken() -> Bool { + do { + let accessToken = try getAccessToken() + let refreshToken = try getRefreshToken() + return accessToken != nil && refreshToken != nil + } catch { + // 키체인 접근 오류 발생 시 로깅하고 false 반환 + print("AuthPreferences - 키체인 접근 오류: \(error)") + return false + } + } + + // 에러를 전파하는 버전 (필요한 경우 사용) + func hasTokenSafely() throws -> Bool { + let accessToken = try getAccessToken() + let refreshToken = try getRefreshToken() + return accessToken != nil && refreshToken != nil + } + + func clear() { + do { + try keychain.delete(Keys.accessToken) + try keychain.delete(Keys.refreshToken) + try keychain.delete(Keys.userId) + try keychain.delete(Keys.userName) + try keychain.delete(Keys.userEmail) + try keychain.delete(Keys.userRole) + try keychain.delete(Keys.expiresIn) + try keychain.delete(Keys.position) + try keychain.delete(Keys.workspace) + try keychain.delete(Keys.branch) + try keychain.delete(Keys.agencyId) + try keychain.delete(Keys.startedAt) + try keychain.delete(Keys.endedAt) + } catch { + // 로그아웃 시에는 실패해도 에러를 던지지 않음 (이미 로그아웃 상태로 간주) + print("AuthPreferences - 키체인 삭제 실패: \(error)") + } + } +} + + diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift new file mode 100644 index 0000000..2354d3f --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/KeychainManager.swift @@ -0,0 +1,100 @@ +// +// KeychainManager.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import Security + +class KeychainManager { + enum KeychainError: Error { + case duplicateItem + case unknown(OSStatus) + case itemNotFound + } + + private let service: String + + init(service: String = Bundle.main.bundleIdentifier ?? "com.sampoom.ios") { + self.service = service + } + + func save(_ value: String, for key: String) throws { + let data = value.data(using: .utf8)! + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + // 기존 항목 삭제 + SecItemDelete(query as CFDictionary) + + // 새 항목 추가 + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + } + + func get(_ key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status != errSecItemNotFound else { + return nil + } + + guard status == errSecSuccess else { + throw KeychainError.unknown(status) + } + + guard let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + func delete(_ key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unknown(status) + } + } + + func deleteAll() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unknown(status) + } + } +} + diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift new file mode 100644 index 0000000..3a53984 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -0,0 +1,84 @@ +// +// AuthMappers.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +extension LoginResponseDTO { + func toModel() -> User { + return User( + id: self.userId, + name: "", + email: "", + role: .user, + accessToken: self.accessToken, + refreshToken: self.refreshToken, + expiresIn: self.expiresIn, + position: .staff, + workspace: "", + branch: "", + agencyId: 0, + startedAt: nil, + endedAt: nil + ) + } +} + +extension GetProfileResponseDTO { + func toModel() -> User { + return User( + id: self.userId, + name: self.userName, + email: self.email, + role: UserRole(rawValue: self.role) ?? .user, + accessToken: "", + refreshToken: "", + expiresIn: 0, + position: UserPosition(rawValue: self.position) ?? .staff, + workspace: self.workspace, + branch: self.branch, + agencyId: self.organizationId, + startedAt: self.startedAt.isEmpty ? nil : self.startedAt, + endedAt: self.endedAt + ) + } +} + +extension User { + func mergeWith(profile: User) -> User { + return User( + id: self.id, + name: profile.name, + email: profile.email, + role: profile.role, + accessToken: self.accessToken, + refreshToken: self.refreshToken, + expiresIn: self.expiresIn, + position: profile.position, + workspace: profile.workspace, + branch: profile.branch, + agencyId: profile.agencyId, + startedAt: profile.startedAt, + endedAt: profile.endedAt + ) + } +} + +// MARK: - Vendors + +extension GetVendorsResponseDTO { + func toModel() -> Vendor { + return Vendor( + id: id, + vendorCode: vendorCode, + name: name, + businessNumber: businessNumber, + ceoName: ceoName, + address: address, + status: status + ) + } +} diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift new file mode 100644 index 0000000..6b880fb --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -0,0 +1,116 @@ +// +// AuthAPI.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import Alamofire + +class AuthAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 로그인 + func login(email: String, password: String) async throws -> APIResponse { + let requestDTO = LoginRequestDTO(workspace: "AGENCY", email: email, password: password) + + let parameters: [String: Any] = [ + "workspace": requestDTO.workspace, + "email": requestDTO.email, + "password": requestDTO.password + ] + + return try await networkManager.request( + endpoint: "auth/login", + method: .post, + parameters: parameters, + responseType: LoginResponseDTO.self + ) + } + + // 회원가입 + func signup( + email: String, + password: String, + workspace: String, + branch: String, + userName: String, + position: String + ) async throws -> APIResponse { + 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 + ) + } + + // 로그아웃 + func logout() async throws -> APIResponse { + return try await networkManager.request( + endpoint: "auth/logout", + method: .post, + parameters: nil, + responseType: EmptyResponse.self + ) + } + + // 벤더 목록 조회 + func getVendors() async throws -> APIResponse<[GetVendorsResponseDTO]> { + return try await networkManager.request( + endpoint: "site/vendors", + method: .get, + responseType: [GetVendorsResponseDTO].self + ) + } + + // 토큰 재발급 + func refresh(refreshToken: String) async throws -> APIResponse { + let requestDTO = RefreshRequestDTO(refreshToken: refreshToken) + + let parameters: [String: Any] = [ + "refreshToken": requestDTO.refreshToken + ] + + return try await networkManager.request( + endpoint: "auth/refresh", + method: .post, + parameters: parameters, + responseType: RefreshResponseDTO.self + ) + } + + // 프로필 조회 + func getProfile(workspace: String = "AGENCY") async throws -> APIResponse { + return try await networkManager.request( + endpoint: "user/profile?workspace=\(workspace)", + method: .get, + parameters: nil, + responseType: GetProfileResponseDTO.self + ) + } +} + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift new file mode 100644 index 0000000..9c86aff --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift @@ -0,0 +1,23 @@ +// +// GetProfileResponseDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct GetProfileResponseDTO: Codable { + let userId: Int + let userName: String + let email: String + let role: String + let position: String + let workspace: String + let branch: String + let organizationId: Int + let startedAt: String + let endedAt: String? +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/GetVendorsResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetVendorsResponseDTO.swift new file mode 100644 index 0000000..223cde1 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetVendorsResponseDTO.swift @@ -0,0 +1,20 @@ +// +// GetVendorsResponseDTO.swift +// SampoomManagement +// +// Created to mirror Android vendor list API. +// + +import Foundation + +struct GetVendorsResponseDTO: Codable { + let id: Int + let vendorCode: String + let name: String + let businessNumber: String + let ceoName: String + let address: String + let status: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift new file mode 100644 index 0000000..9a9922f --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginRequestDTO.swift @@ -0,0 +1,16 @@ +// +// LoginRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginRequestDTO: Codable { + let workspace: String + let email: String + let password: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift new file mode 100644 index 0000000..6d22ed4 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift @@ -0,0 +1,17 @@ +// +// LoginResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginResponseDTO: Codable { + let userId: Int + let accessToken: String + let refreshToken: String + let expiresIn: Int +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift new file mode 100644 index 0000000..b74cc03 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshRequestDTO.swift @@ -0,0 +1,12 @@ +// +// RefreshRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +struct RefreshRequestDTO: Codable { + let refreshToken: String +} diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift new file mode 100644 index 0000000..fa969c6 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/RefreshResponseDTO.swift @@ -0,0 +1,14 @@ +// +// RefreshResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +struct RefreshResponseDTO: Codable { + let accessToken: String + let expiresIn: Int + let refreshToken: String +} diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift new file mode 100644 index 0000000..e1d586c --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupRequestDTO.swift @@ -0,0 +1,19 @@ +// +// SignupRequestDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignupRequestDTO: Codable { + let userName: String + let workspace: String + let branch: String + let position: String + let email: String + let password: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift new file mode 100644 index 0000000..eea88cf --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/SignupResponseDTO.swift @@ -0,0 +1,16 @@ +// +// SignupResponseDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignupResponseDTO: Codable { + let userId: Int + let userName: String + let email: String +} + + diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift new file mode 100644 index 0000000..3377bd8 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,201 @@ +// +// AuthRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthRepositoryImpl: AuthRepository { + private let api: AuthAPI + private let preferences: AuthPreferences + + init(api: AuthAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + // 회원가입 + func signUp( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User { + _ = try await api.signup( + email: email, + password: password, + workspace: workspace, + branch: branch, + userName: userName, + position: position + ) + // 회원가입 직후 DB 반영 지연을 고려하여 잠시 대기 후 재시도하며 로그인 + // 0.5초 대기 후 최대 5회 재시도 (지수 백오프) + try await Task.sleep(nanoseconds: 500_000_000) + var currentDelayMs: UInt64 = 500 + for attempt in 1...5 { + do { + return try await signIn(email: email, password: password) + } catch { + if attempt == 5 { throw error } + try await Task.sleep(nanoseconds: currentDelayMs * 1_000_000) + currentDelayMs = min(currentDelayMs &* 2, 2_000) + } + } + throw AuthError.invalidResponse + } + + func signIn(email: String, password: String) async throws -> User { + // 1) 로그인 + let loginResponse = try await api.login(email: email, password: password) + guard let loginDto = loginResponse.data else { + throw AuthError.invalidResponse + } + let loginUser = loginDto.toModel() + // Store tokens immediately so that subsequent authorized calls (e.g., getProfile) carry Authorization header + do { + try preferences.saveToken(accessToken: loginUser.accessToken, refreshToken: loginUser.refreshToken) + } catch { + print("AuthRepositoryImpl - 초기 토큰 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + // 2) 프로필 조회 (서버 반영 지연 고려하여 재시도, 최종 실패 시 롤백) + let profileUser: User + do { + profileUser = try await retry(times: 5, initialDelayMs: 300, maxDelayMs: 1500, factor: 1.8) { + let profileResponse = try await self.api.getProfile() + guard let profileDto = profileResponse.data else { + throw AuthError.invalidResponse + } + return profileDto.toModel() + } + } catch { + // rollback tokens on any profile failure + preferences.clear() + throw error + } + + // 3) 병합 + let mergedUser = loginUser.mergeWith(profile: profileUser) + + // 4) 저장 + do { + try preferences.saveUser(mergedUser) + } catch { + print("AuthRepositoryImpl - 키체인 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + return mergedUser + } + + func signOut() async throws { + // API 호출 실패해도 토큰은 삭제 (이미 로그아웃 상태로 간주) + do { + _ = try await api.logout() + } catch { + print("AuthRepositoryImpl - 로그아웃 API 호출 실패: \(error)") + // API 실패해도 토큰은 삭제 + } + + preferences.clear() + } + + func refreshToken() async throws -> User { + guard let refreshToken = try preferences.getRefreshToken() else { + throw AuthError.tokenRefreshFailed + } + + let response = try await api.refresh(refreshToken: refreshToken) + guard let dto = response.data else { + throw AuthError.invalidResponse + } + + // 기존 사용자 정보 조회 + guard let existingUser = try preferences.getStoredUser() else { + throw AuthError.tokenRefreshFailed + } + + // 새로운 토큰 정보로 사용자 정보 업데이트 + let updatedUser = User( + id: existingUser.id, + name: existingUser.name, + email: existingUser.email, + role: existingUser.role, + accessToken: dto.accessToken, + refreshToken: dto.refreshToken, + expiresIn: dto.expiresIn, + position: existingUser.position, + workspace: existingUser.workspace, + branch: existingUser.branch, + agencyId: existingUser.agencyId, + startedAt: existingUser.startedAt, + endedAt: existingUser.endedAt + ) + + do { + try preferences.saveUser(updatedUser) + } catch { + print("AuthRepositoryImpl - 토큰 갱신 후 키체인 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + return updatedUser + } + + func clearTokens() async throws { + preferences.clear() + } + + func isSignedIn() -> Bool { + return preferences.hasToken() + } + + // 토큰 조회 (API 요청 시 사용) + func getAccessToken() throws -> String? { + return try preferences.getAccessToken() + } + + func getRefreshToken() throws -> String? { + return try preferences.getRefreshToken() + } + + // MARK: - Vendors + func getVendorList() async throws -> VendorList { + let response = try await api.getVendors() + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + let items = (response.data ?? []).map { $0.toModel() } + return VendorList(items: items) + } +} + +// MARK: - Retry Helper (Exponential Backoff) +private func retry( + times: Int = 5, + initialDelayMs: UInt64 = 300, + maxDelayMs: UInt64 = 1500, + factor: Double = 1.8, + _ block: @escaping () async throws -> T +) async throws -> T { + precondition(times >= 1) + var currentDelayMs = initialDelayMs + for _ in 1..<(times) { + do { + return try await block() + } catch { + // Optional: filter retryable errors only + let ns = currentDelayMs * 1_000_000 // ms -> ns + try? await Task.sleep(nanoseconds: ns) + let next = UInt64(Double(currentDelayMs) * factor) + currentDelayMs = min(next, maxDelayMs) + } + } + return try await block() +} diff --git a/SampoomManagement/Features/Auth/Domain/AuthValidator.swift b/SampoomManagement/Features/Auth/Domain/AuthValidator.swift new file mode 100644 index 0000000..3b651f5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/AuthValidator.swift @@ -0,0 +1,72 @@ +// +// AuthValidator.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class AuthValidator { + // 빈 값 검증 + static func validateNotEmpty(_ value: String, _ label: String) -> ValidationResult { + if value.trimmingCharacters(in: .whitespaces).isEmpty { + return .error(StringResources.Auth.fieldRequired(label)) + } + return .success + } + + // 이메일 형식 검증 + static func validateEmail(_ email: String) -> ValidationResult { + let trimmedEmail = email.trimmingCharacters(in: .whitespaces) + + if trimmedEmail.isEmpty { + return .error(StringResources.Auth.emailRequired) + } + + let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + + if !emailPredicate.evaluate(with: trimmedEmail) { + return .error(StringResources.Auth.emailInvalid) + } + + return .success + } + + // 비밀번호 검증 + static func validatePassword(_ password: String) -> ValidationResult { + if password.isEmpty { + return .error(StringResources.Auth.passwordRequired) + } + + if password.count < 8 { + return .error(StringResources.Auth.passwordTooShort) + } + + // 영문, 숫자 포함 여부 확인 + let hasLetter = password.rangeOfCharacter(from: .letters) != nil + let hasNumber = password.rangeOfCharacter(from: .decimalDigits) != nil + + if !hasLetter || !hasNumber { + return .error(StringResources.Auth.passwordInvalid) + } + + return .success + } + + // 비밀번호 확인 검증 + static func validatePasswordCheck(_ password: String, _ passwordCheck: String) -> ValidationResult { + if passwordCheck.isEmpty { + return .error(StringResources.Auth.passwordCheckRequired) + } + + if password != passwordCheck { + return .error(StringResources.Auth.passwordCheckMismatch) + } + + return .success + } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/Auth/Domain/Models/User.swift new file mode 100644 index 0000000..46b5d90 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Models/User.swift @@ -0,0 +1,43 @@ +// +// User.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +enum UserRole: String, Codable, Equatable { + case admin = "ADMIN" + case user = "USER" + case manager = "MANAGER" + // Additional roles provided + case staff = "STAFF" + case seniorStaff = "SENIOR_STAFF" + case assistantManager = "ASSISTANT_MANAGER" + case deputyGeneralManager = "DEPUTY_GENERAL_MANAGER" + case generalManager = "GENERAL_MANAGER" + case director = "DIRECTOR" + case vicePresident = "VICE_PRESIDENT" + case president = "PRESIDENT" + case chairman = "CHAIRMAN" + + var isAdmin: Bool { self == .admin } +} + +struct User: Equatable { + let id: Int + let name: String + let email: String + let role: UserRole + let accessToken: String + let refreshToken: String + let expiresIn: Int + // Additional profile fields merged after login + let position: UserPosition + let workspace: String + let branch: String + let agencyId: Int + let startedAt: String? + let endedAt: String? +} diff --git a/SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift b/SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift new file mode 100644 index 0000000..1d7b603 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift @@ -0,0 +1,38 @@ +// +// UserPosition.swift +// SampoomManagement +// +// Created by AI on 11/5/25. +// + +import Foundation + +enum UserPosition: String, CaseIterable, Codable, Equatable, Hashable { + case staff = "STAFF" + case seniorStaff = "SENIOR_STAFF" + case assistantManager = "ASSISTANT_MANAGER" + case manager = "MANAGER" + case deputyGeneralManager = "DEPUTY_GENERAL_MANAGER" + case generalManager = "GENERAL_MANAGER" + case director = "DIRECTOR" + case vicePresident = "VICE_PRESIDENT" + case president = "PRESIDENT" + case chairman = "CHAIRMAN" + + var displayNameKo: String { + switch self { + case .staff: return "사원" + case .seniorStaff: return "주임" + case .assistantManager: return "대리" + case .manager: return "과장" + case .deputyGeneralManager: return "차장" + case .generalManager: return "부장" + case .director: return "이사" + case .vicePresident: return "부사장" + case .president: return "사장" + case .chairman: return "회장" + } + } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Models/Vendor.swift b/SampoomManagement/Features/Auth/Domain/Models/Vendor.swift new file mode 100644 index 0000000..020aaa0 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Models/Vendor.swift @@ -0,0 +1,20 @@ +// +// Vendor.swift +// SampoomManagement +// +// Created to mirror Android vendor domain model. +// + +import Foundation + +struct Vendor: Equatable, Identifiable { + let id: Int + let vendorCode: String + let name: String + let businessNumber: String + let ceoName: String + let address: String + let status: String +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Models/VendorList.swift b/SampoomManagement/Features/Auth/Domain/Models/VendorList.swift new file mode 100644 index 0000000..7dadeb5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Models/VendorList.swift @@ -0,0 +1,17 @@ +// +// VendorList.swift +// SampoomManagement +// +// Created to mirror Android vendor list domain model. +// + +import Foundation + +struct VendorList: Equatable { + let items: [Vendor] + var totalCount: Int { items.count } + var isEmpty: Bool { items.isEmpty } + static func empty() -> VendorList { VendorList(items: []) } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift new file mode 100644 index 0000000..c9e7c43 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/Repository/AuthRepository.swift @@ -0,0 +1,32 @@ +// +// AuthRepository.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +protocol AuthRepository { + func signUp( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User + + func signIn(email: String, password: String) async throws -> User + func signOut() async throws + func refreshToken() async throws -> User + func clearTokens() async throws + func isSignedIn() -> Bool + + // 토큰 조회 (API 요청 시 사용) + func getAccessToken() throws -> String? + func getRefreshToken() throws -> String? + + // 벤더 목록 조회 + func getVendorList() async throws -> VendorList +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift new file mode 100644 index 0000000..18ce2e1 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/CheckLoginStateUseCase.swift @@ -0,0 +1,20 @@ +// +// CheckLoginStateUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class CheckLoginStateUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() -> Bool { + return repository.isSignedIn() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift new file mode 100644 index 0000000..61f8968 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/ClearTokensUseCase.swift @@ -0,0 +1,20 @@ +// +// ClearTokensUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class ClearTokensUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.clearTokens() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/GetVendorUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/GetVendorUseCase.swift new file mode 100644 index 0000000..d98977d --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/GetVendorUseCase.swift @@ -0,0 +1,22 @@ +// +// GetVendorUseCase.swift +// SampoomManagement +// +// Mirrors Android GetVendorUseCase. +// + +import Foundation + +struct GetVendorUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() async throws -> VendorList { + return try await repository.getVendorList() + } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift new file mode 100644 index 0000000..9f3b9b5 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/LoginUseCase.swift @@ -0,0 +1,21 @@ +// +// LoginUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class LoginUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute(email: String, password: String) async throws -> User { + return try await repository.signIn(email: email, password: password) + } +} + diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift new file mode 100644 index 0000000..130742c --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/SignOutUseCase.swift @@ -0,0 +1,20 @@ +// +// SignOutUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation + +class SignOutUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute() async throws { + try await repository.signOut() + } +} diff --git a/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift b/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift new file mode 100644 index 0000000..4ccf761 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/UseCase/SignUpUseCase.swift @@ -0,0 +1,35 @@ +// +// SignUpUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +class SignUpUseCase { + private let repository: AuthRepository + + init(repository: AuthRepository) { + self.repository = repository + } + + func execute( + userName: String, + workspace: String, + branch: String, + position: String, + email: String, + password: String + ) async throws -> User { + return try await repository.signUp( + userName: userName, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + } +} + diff --git a/SampoomManagement/Features/Auth/Domain/ValidationResult.swift b/SampoomManagement/Features/Auth/Domain/ValidationResult.swift new file mode 100644 index 0000000..5e10ad1 --- /dev/null +++ b/SampoomManagement/Features/Auth/Domain/ValidationResult.swift @@ -0,0 +1,29 @@ +// +// ValidationResult.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum ValidationResult { + case success + case error(String) + + var isSuccess: Bool { + if case .success = self { + return true + } + return false + } + + var errorMessage: String? { + if case .error(let message) = self { + return message + } + return nil + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift new file mode 100644 index 0000000..5691676 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift @@ -0,0 +1,72 @@ +// +// AuthViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/15/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class AuthViewModel: ObservableObject { + // MARK: - Properties + @Published var isLoggedIn: Bool = false + @Published var shouldNavigateToLogin: Bool = false + + private let checkLoginStateUseCase: CheckLoginStateUseCase + private let signOutUseCase: SignOutUseCase + private let clearTokensUseCase: ClearTokensUseCase + + // MARK: - Initialization + init( + checkLoginStateUseCase: CheckLoginStateUseCase, + signOutUseCase: SignOutUseCase, + clearTokensUseCase: ClearTokensUseCase + ) { + self.checkLoginStateUseCase = checkLoginStateUseCase + self.signOutUseCase = signOutUseCase + self.clearTokensUseCase = clearTokensUseCase + + updateLoginState() + } + + // MARK: - Actions + func updateLoginState() { + isLoggedIn = checkLoginStateUseCase.execute() + } + + func signOut() async { + do { + try await signOutUseCase.execute() + } catch { + print("AuthViewModel - 로그아웃 실패: \(error)") + } + + // 로그아웃 성공/실패 관계없이 로컬 상태 업데이트 + handleSignedOutState() + } + + func handleTokenExpired() async { + do { + try await clearTokensUseCase.execute() + isLoggedIn = false + shouldNavigateToLogin = true + } catch { + print("AuthViewModel - 토큰 삭제 실패: \(error)") + // 토큰 삭제 실패해도 로그아웃 처리 + isLoggedIn = false + shouldNavigateToLogin = true + } + } + + func resetNavigationState() { + shouldNavigateToLogin = false + } + + func handleSignedOutState() { + isLoggedIn = false + shouldNavigateToLogin = true + } +} diff --git a/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift b/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift new file mode 100644 index 0000000..8a6d00e --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginUiEvent.swift @@ -0,0 +1,16 @@ +// +// LoginUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum LoginUiEvent { + case emailChanged(String) + case passwordChanged(String) + case submit +} + + diff --git a/SampoomManagement/Features/Auth/UI/LoginUiState.swift b/SampoomManagement/Features/Auth/UI/LoginUiState.swift new file mode 100644 index 0000000..e9e468c --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginUiState.swift @@ -0,0 +1,68 @@ +// +// LoginUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct LoginUiState: UIState { + let email: String + let password: String + + // Error message + let emailError: String? + let passwordError: String? + + let loading: Bool + let error: String? + let success: Bool + + init( + email: String = "", + password: String = "", + emailError: String? = nil, + passwordError: String? = nil, + loading: Bool = false, + error: String? = nil, + success: Bool = false + ) { + self.email = email + self.password = password + self.emailError = emailError + self.passwordError = passwordError + self.loading = loading + self.error = error + self.success = success + } + + var isValid: Bool { + return !email.isEmpty && + !password.isEmpty && + emailError == nil && + passwordError == nil + } + + func copy( + email: String? = nil, + password: String? = nil, + emailError: String?? = nil, + passwordError: String?? = nil, + loading: Bool? = nil, + error: String?? = nil, + success: Bool? = nil + ) -> LoginUiState { + return LoginUiState( + email: email ?? self.email, + password: password ?? self.password, + emailError: emailError ?? self.emailError, + passwordError: passwordError ?? self.passwordError, + loading: loading ?? self.loading, + error: error ?? self.error, + success: success ?? self.success + ) + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/LoginView.swift b/SampoomManagement/Features/Auth/UI/LoginView.swift new file mode 100644 index 0000000..7faa0b4 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginView.swift @@ -0,0 +1,124 @@ +// +// LoginView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import SwiftUI +import Toast + +struct LoginView: View { + @ObservedObject var viewModel: LoginViewModel + @StateObject private var keyboardObserver = KeyboardObserver() + @State private var email = "" + @State private var password = "" + + let onSuccess: () -> Void + let onNavigateSignUp: () -> Void + + init(viewModel: LoginViewModel, onSuccess: @escaping () -> Void, onNavigateSignUp: @escaping () -> Void) { + self.viewModel = viewModel + self.onSuccess = onSuccess + self.onNavigateSignUp = onNavigateSignUp + } + + var body: some View { + VStack { + Spacer() + + // 로고 + Image("square_logo") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + + Spacer() + .frame(height: 48) + + // 이메일 입력 + CommonTextField( + value: $email, + placeholder: StringResources.Auth.emailPlaceholder, + type: .email, + isError: viewModel.uiState.emailError != nil, + errorMessage: viewModel.uiState.emailError + ) { text in + viewModel.updateEmail(text) + } + + Spacer() + .frame(height: 16) + + // 비밀번호 입력 + CommonTextField( + value: $password, + placeholder: StringResources.Auth.passwordPlaceholder, + type: .password, + isError: viewModel.uiState.passwordError != nil, + errorMessage: viewModel.uiState.passwordError + ) { text in + viewModel.updatePassword(text) + } + + Spacer() + .frame(height: 48) + + // 로그인 버튼 + CommonButton( + viewModel.uiState.loading + ? StringResources.Auth.loginButtonLoading + : StringResources.Auth.loginButton, + isEnabled: viewModel.uiState.isValid && !viewModel.uiState.loading + ) { + viewModel.submit() + } + + Spacer() + + // 회원가입 안내 + Button(action: onNavigateSignUp) { + HStack(spacing: 4) { + Text(StringResources.Auth.needAccount) + .font(.gmarketBody) + .foregroundColor(.text) + Text(StringResources.Auth.signUpLink) + .font(.gmarketBody) + .foregroundColor(.accentColor) + .underline() + + + Text(StringResources.Auth.signUpDo) + .font(.gmarketBody) + .foregroundColor(.text) + } + } + .padding(.bottom, 32) + } + .padding(.horizontal, 16) + //.offset(y: keyboardObserver.keyboardHeight > 0 ? -keyboardObserver.keyboardHeight / 2.5 : 0) + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() + } + .onChange(of: viewModel.uiState.error) { _, error in + if let message = error, !message.isEmpty { + // 타임스탬프 제거하여 순수한 에러 메시지만 표시 + let cleanMessage = message.components(separatedBy: "_").first ?? message + Toast.text(cleanMessage).show() + viewModel.consumeError() + } + } + .onChange(of: viewModel.uiState.success) { _, success in + if success { + onSuccess() + } + } + } + + // MARK: - Helper Methods + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift new file mode 100644 index 0000000..0671667 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -0,0 +1,90 @@ +// +// LoginViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class LoginViewModel: ObservableObject { + @Published var uiState = LoginUiState() + + private let loginUseCase: LoginUseCase + + init(loginUseCase: LoginUseCase) { + self.loginUseCase = loginUseCase + } + + // 이메일 업데이트 + func updateEmail(_ email: String) { + uiState = uiState.copy(email: email) + validateEmail() + } + + // 비밀번호 업데이트 + func updatePassword(_ password: String) { + uiState = uiState.copy(password: password) + validatePassword() + } + + // 로그인 제출 + func submit() { + Task { + validateEmail() + validatePassword() + + guard uiState.isValid else { return } + + let email = uiState.email + let password = uiState.password + + uiState = uiState.copy(loading: true, error: nil) + + do { + _ = try await loginUseCase.execute(email: email, password: password) + uiState = uiState.copy(loading: false, success: true) + } catch { + uiState = uiState.copy(loading: false) + showError(error.localizedDescription) + } + } + } + + // 에러 소비 (Toast 표시 후 에러 상태 제거) + func consumeError() { + uiState = uiState.copy(error: nil) + } + + // 에러 표시를 위한 강제 상태 변경 + private func showError(_ message: String) { + // 타임스탬프를 추가하여 항상 다른 값으로 만들어 onChange 트리거 보장 + uiState = uiState.copy(error: "\(message)_\(Date().timeIntervalSince1970)") + } + + // MARK: - Private Methods + + private func validateEmail() { + let emptyResult = AuthValidator.validateNotEmpty( + uiState.email, + StringResources.Auth.emailLabel + ) + if !emptyResult.isSuccess { + uiState = uiState.copy(emailError: emptyResult.errorMessage) + return + } + let emailResult = AuthValidator.validateEmail(uiState.email) + uiState = uiState.copy(emailError: emailResult.errorMessage) + } + + private func validatePassword() { + let result = AuthValidator.validateNotEmpty( + uiState.password, + StringResources.Auth.passwordLabel + ) + uiState = uiState.copy(passwordError: result.errorMessage) + } +} diff --git a/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift b/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift new file mode 100644 index 0000000..ab58fc8 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpUiEvent.swift @@ -0,0 +1,20 @@ +// +// SignUpUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +enum SignUpUiEvent { + case nameChanged(String) + case branchChanged(String) + case positionChanged(String) + case emailChanged(String) + case passwordChanged(String) + case passwordCheckChanged(String) + case submit +} + + diff --git a/SampoomManagement/Features/Auth/UI/SignUpUiState.swift b/SampoomManagement/Features/Auth/UI/SignUpUiState.swift new file mode 100644 index 0000000..3d0dfe4 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpUiState.swift @@ -0,0 +1,138 @@ +// +// SignUpUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation + +struct SignUpUiState: UIState { + let name: String + let workspace: String + let branch: String + let position: String + let email: String + let password: String + let passwordCheck: String + + // Vendor + let vendors: [Vendor] + let selectedVendor: Vendor? + let vendorsLoading: Bool + + // Error message + let nameError: String? + let branchError: String? + let positionError: String? + let emailError: String? + let passwordError: String? + let passwordCheckError: String? + + let loading: Bool + let error: String? + let success: Bool + + init( + name: String = "", + workspace: String = "AGENCY", + branch: String = "", + position: String = "", + email: String = "", + password: String = "", + passwordCheck: String = "", + vendors: [Vendor] = [], + selectedVendor: Vendor? = nil, + vendorsLoading: Bool = false, + nameError: String? = nil, + branchError: String? = nil, + positionError: String? = nil, + emailError: String? = nil, + passwordError: String? = nil, + passwordCheckError: String? = nil, + loading: Bool = false, + error: String? = nil, + success: Bool = false + ) { + self.name = name + self.workspace = workspace + self.branch = branch + self.position = position + self.email = email + self.password = password + self.passwordCheck = passwordCheck + self.vendors = vendors + self.selectedVendor = selectedVendor + self.vendorsLoading = vendorsLoading + self.nameError = nameError + self.branchError = branchError + self.positionError = positionError + self.emailError = emailError + self.passwordError = passwordError + self.passwordCheckError = passwordCheckError + self.loading = loading + self.error = error + self.success = success + } + + var isValid: Bool { + return !name.isEmpty && + !branch.isEmpty && + !position.isEmpty && + !email.isEmpty && + !password.isEmpty && + !passwordCheck.isEmpty && + nameError == nil && + branchError == nil && + positionError == nil && + emailError == nil && + passwordError == nil && + passwordCheckError == nil + } + + func copy( + name: String? = nil, + workspace: String? = nil, + branch: String? = nil, + position: String? = nil, + email: String? = nil, + password: String? = nil, + passwordCheck: String? = nil, + vendors: [Vendor]? = nil, + selectedVendor: Vendor?? = nil, + vendorsLoading: Bool? = nil, + nameError: String?? = nil, + branchError: String?? = nil, + positionError: String?? = nil, + emailError: String?? = nil, + passwordError: String?? = nil, + passwordCheckError: String?? = nil, + loading: Bool? = nil, + error: String?? = nil, + success: Bool? = nil + ) -> SignUpUiState { + return SignUpUiState( + name: name ?? self.name, + workspace: workspace ?? self.workspace, + branch: branch ?? self.branch, + position: position ?? self.position, + email: email ?? self.email, + password: password ?? self.password, + passwordCheck: passwordCheck ?? self.passwordCheck, + vendors: vendors ?? self.vendors, + selectedVendor: selectedVendor ?? self.selectedVendor, + vendorsLoading: vendorsLoading ?? self.vendorsLoading, + nameError: nameError ?? self.nameError, + branchError: branchError ?? self.branchError, + positionError: positionError ?? self.positionError, + emailError: emailError ?? self.emailError, + passwordError: passwordError ?? self.passwordError, + passwordCheckError: passwordCheckError ?? self.passwordCheckError, + loading: loading ?? self.loading, + error: error ?? self.error, + success: success ?? self.success + ) + } +} + + diff --git a/SampoomManagement/Features/Auth/UI/SignUpView.swift b/SampoomManagement/Features/Auth/UI/SignUpView.swift new file mode 100644 index 0000000..4df708b --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpView.swift @@ -0,0 +1,260 @@ +// +// SignUpView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import SwiftUI +import Toast + +struct SignUpView: View { + @ObservedObject var viewModel: SignUpViewModel + @StateObject private var keyboardObserver = KeyboardObserver() + @State private var name = "" + @State private var branch = "" + @State private var selectedPosition: UserPosition? = nil + @State private var email = "" + @State private var password = "" + @State private var passwordCheck = "" + @State private var showVendorSheet = false + @FocusState private var focusedField: Field? + + let onSuccess: () -> Void + + private let labelTextSize: CGFloat = 16 + + private enum Field: Hashable { + case name, branch, position, email, password, passwordCheck + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // 로고 + Image("oneline_logo") + .resizable() + .scaledToFit() + .frame(width: 120) + .frame(alignment: .leading) + + Spacer() + .frame(height: 48) + + // 이름 + Text(StringResources.Auth.nameLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $name, + placeholder: StringResources.Auth.namePlaceholder, + isError: viewModel.uiState.nameError != nil, + errorMessage: viewModel.uiState.nameError, + onTextChange: { text in viewModel.updateName(text) }, + submitLabel: .next, + onSubmit: { focusedField = .branch } + ) + .focused($focusedField, equals: .name) + + Spacer() + .frame(height: 8) + + // 지점 + Text(StringResources.Auth.branchLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + Button(action: { showVendorSheet = true }) { + HStack { + Text(viewModel.uiState.selectedVendor?.name ?? StringResources.Auth.branchPlaceholder) + .font(.gmarketBody) + .foregroundColor(viewModel.uiState.selectedVendor == nil ? Color.gray : Color("Text")) + Spacer() + Image(systemName: "chevron.down") + .foregroundColor(Color.gray) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(viewModel.uiState.branchError != nil ? Color.red : Color.gray.opacity(0.4), lineWidth: 1) + ) + } + .sheet(isPresented: $showVendorSheet) { + NavigationStack { + List(viewModel.uiState.vendors, id: \.id) { vendor in + Button(action: { + viewModel.selectVendor(vendor) + showVendorSheet = false + }) { + VStack(alignment: .leading, spacing: 2) { + Text(vendor.name) + .foregroundColor(Color("Text")) + Text(vendor.vendorCode) + .font(.gmarketCaption) + .foregroundColor(Color("TextSecondary")) + } + } + } + .navigationTitle(StringResources.Auth.branchLabel) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(StringResources.Common.close) { showVendorSheet = false } + } + } + } + } + .onChange(of: viewModel.uiState.selectedVendor) { _, newValue in + branch = newValue?.name ?? "" + if let b = newValue?.name { viewModel.updateBranch(b) } + } + .focused($focusedField, equals: .branch) + + Spacer() + .frame(height: 8) + + // 직급 + Text(StringResources.Auth.positionLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + Menu { + ForEach(UserPosition.allCases, id: \.self) { pos in + Button(action: { + selectedPosition = pos + viewModel.updatePosition(pos.rawValue) + focusedField = .email + }) { + Text(pos.displayNameKo) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + } + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(selectedPosition?.displayNameKo ?? StringResources.Auth.positionPlaceholder) + .font(.gmarketBody) + .foregroundColor(selectedPosition == nil ? .gray : Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .padding(4) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(viewModel.uiState.positionError != nil ? Color.red : Color.gray.opacity(0.4), lineWidth: 1) + ) + if let error = viewModel.uiState.positionError { + Text(error) + .font(.gmarketBody) + .foregroundColor(.red) + .padding(.leading, 4) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .focused($focusedField, equals: .position) + + Spacer() + .frame(height: 8) + + // 이메일 + Text(StringResources.Auth.emailLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $email, + placeholder: StringResources.Auth.emailPlaceholder, + type: .email, + isError: viewModel.uiState.emailError != nil, + errorMessage: viewModel.uiState.emailError, + onTextChange: { text in viewModel.updateEmail(text) }, + submitLabel: .next, + onSubmit: { focusedField = .password } + ) + .focused($focusedField, equals: .email) + + Spacer() + .frame(height: 8) + + // 비밀번호 + Text(StringResources.Auth.passwordLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $password, + placeholder: StringResources.Auth.passwordPlaceholder, + type: .password, + isError: viewModel.uiState.passwordError != nil, + errorMessage: viewModel.uiState.passwordError, + onTextChange: { text in viewModel.updatePassword(text) }, + submitLabel: .next, + onSubmit: { focusedField = .passwordCheck } + ) + .focused($focusedField, equals: .password) + + Spacer() + .frame(height: 8) + + // 비밀번호 확인 + Text(StringResources.Auth.passwordCheckLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .padding(.bottom, 4) + CommonTextField( + value: $passwordCheck, + placeholder: StringResources.Auth.passwordCheckPlaceholder, + type: .password, + isError: viewModel.uiState.passwordCheckError != nil, + errorMessage: viewModel.uiState.passwordCheckError, + onTextChange: { text in viewModel.updatePasswordCheck(text) }, + submitLabel: .done, + onSubmit: { focusedField = nil } + ) + .focused($focusedField, equals: .passwordCheck) + + Spacer() + .frame(height: 48) + + // 회원가입 버튼 + CommonButton( + viewModel.uiState.loading + ? StringResources.Auth.signUpButtonLoading + : StringResources.Auth.signUpButton, + isEnabled: viewModel.uiState.isValid && !viewModel.uiState.loading + ) { + viewModel.submit() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + .contentShape(Rectangle()) + .onTapGesture { + hideKeyboard() + } + .onChange(of: viewModel.uiState.success) { _, success in + if success { + onSuccess() + } + } + .onChange(of: viewModel.uiState.error) { _, error in + if let message = error, !message.isEmpty { + // 타임스탬프 제거하여 순수한 에러 메시지만 표시 + let cleanMessage = message.components(separatedBy: "_").first ?? message + Toast.text(cleanMessage).show() + viewModel.consumeError() + } + } + } + + // MARK: - Helper Methods + + private func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift new file mode 100644 index 0000000..6a43df3 --- /dev/null +++ b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift @@ -0,0 +1,173 @@ +// +// SignUpViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/14/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class SignUpViewModel: ObservableObject { + @Published var uiState = SignUpUiState() + + private let signUpUseCase: SignUpUseCase + private let getVendorUseCase: GetVendorUseCase + + init(signUpUseCase: SignUpUseCase, getVendorUseCase: GetVendorUseCase) { + self.signUpUseCase = signUpUseCase + self.getVendorUseCase = getVendorUseCase + Task { await loadVendors() } + } + + // 이름 업데이트 + func updateName(_ name: String) { + uiState = uiState.copy(name: name) + validateName() + } + + // 지점 업데이트 + func updateBranch(_ branch: String) { + uiState = uiState.copy(branch: branch) + validateBranch() + } + + // Vendor 선택 시 브랜치 채우기 + func selectVendor(_ vendor: Vendor) { + uiState = uiState.copy(branch: vendor.name, selectedVendor: vendor) + validateBranch() + } + + // 직급 업데이트 + func updatePosition(_ position: String) { + uiState = uiState.copy(position: position) + validatePosition() + } + + // 이메일 업데이트 + func updateEmail(_ email: String) { + uiState = uiState.copy(email: email) + validateEmail() + } + + // 비밀번호 업데이트 + func updatePassword(_ password: String) { + uiState = uiState.copy(password: password) + validatePassword() + if !uiState.passwordCheck.isEmpty { + validatePasswordCheck() + } + } + + // 비밀번호 확인 업데이트 + func updatePasswordCheck(_ passwordCheck: String) { + uiState = uiState.copy(passwordCheck: passwordCheck) + validatePasswordCheck() + } + + // 회원가입 제출 + func submit() { + Task { + validateName() + validateBranch() + validatePosition() + validateEmail() + validatePassword() + validatePasswordCheck() + + guard uiState.isValid else { return } + + let name = uiState.name + let workspace = uiState.workspace + let branch = uiState.branch + let position = uiState.position + let email = uiState.email + let password = uiState.password + + uiState = uiState.copy(loading: true, error: nil) + + do { + _ = try await signUpUseCase.execute( + userName: name, + workspace: workspace, + branch: branch, + position: position, + email: email, + password: password + ) + uiState = uiState.copy(loading: false, success: true) + } catch { + uiState = uiState.copy(loading: false) + showError(error.localizedDescription) + } + } + } + + // 에러 소비 (Toast 표시 후 에러 상태 제거) + func consumeError() { + uiState = uiState.copy(error: nil) + } + + // 에러 표시를 위한 강제 상태 변경 + private func showError(_ message: String) { + // 타임스탬프를 추가하여 항상 다른 값으로 만들어 onChange 트리거 보장 + uiState = uiState.copy(error: "\(message)_\(Date().timeIntervalSince1970)") + } + + // MARK: - Private Methods + + private func validateName() { + let result = AuthValidator.validateNotEmpty( + uiState.name, + StringResources.Auth.nameLabel + ) + uiState = uiState.copy(nameError: result.errorMessage) + } + + private func validateBranch() { + let result = AuthValidator.validateNotEmpty( + uiState.branch, + StringResources.Auth.branchLabel + ) + uiState = uiState.copy(branchError: result.errorMessage) + } + + private func loadVendors() async { + uiState = uiState.copy(vendorsLoading: true) + do { + let list = try await getVendorUseCase.execute() + uiState = uiState.copy(vendors: list.items, vendorsLoading: false) + } catch { + // 실패 시에도 로딩 해제만 + uiState = uiState.copy(vendorsLoading: false) + } + } + + private func validatePosition() { + let result = AuthValidator.validateNotEmpty( + uiState.position, + StringResources.Auth.positionLabel + ) + uiState = uiState.copy(positionError: result.errorMessage) + } + + private func validateEmail() { + let result = AuthValidator.validateEmail(uiState.email) + uiState = uiState.copy(emailError: result.errorMessage) + } + + private func validatePassword() { + let result = AuthValidator.validatePassword(uiState.password) + uiState = uiState.copy(passwordError: result.errorMessage) + } + + private func validatePasswordCheck() { + let result = AuthValidator.validatePasswordCheck( + uiState.password, + uiState.passwordCheck + ) + uiState = uiState.copy(passwordCheckError: result.errorMessage) + } +} diff --git a/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift b/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift new file mode 100644 index 0000000..deda377 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Mappers/CartMappers.swift @@ -0,0 +1,34 @@ +// +// CartMappers.swift +// SampoomManagement +// +// Created by 채상윤 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, + standardCost: standardCost + ) + } +} + 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..6bbc341 --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/API/CartAPI.swift @@ -0,0 +1,83 @@ +// +// CartAPI.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation +import Alamofire + +class CartAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 장바구니 목록 조회 + func getCartList(agencyId: Int) async throws -> [CartDto] { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/cart", + method: .get, + responseType: [CartDto].self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + return response.data ?? [] + } + + // 장바구니에 부품 추가 + func addCart(agencyId: Int, request: AddCartRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/cart", + method: .post, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 장바구니 항목 삭제 + func deleteCart(agencyId: Int, cartItemId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/cart/\(cartItemId)", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 장바구니 수량 변경 + func updateCart(agencyId: Int, cartItemId: Int, request: UpdateCartRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/cart/\(cartItemId)", + method: .put, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 장바구니 전체 비우기 + func deleteAllCart(agencyId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/cart/clear", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } +} + 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..c2416bb --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/AddCartRequestDto.swift @@ -0,0 +1,14 @@ +// +// AddCartRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 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..981e97e --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/CartDto.swift @@ -0,0 +1,30 @@ +// +// CartDto.swift +// SampoomManagement +// +// Created by 채상윤 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 + let standardCost: 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..f134a3a --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Remote/DTO/UpdateCartRequestDto.swift @@ -0,0 +1,13 @@ +// +// UpdateCartRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 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..199ac4f --- /dev/null +++ b/SampoomManagement/Features/Cart/Data/Repository/CartRepositoryImpl.swift @@ -0,0 +1,58 @@ +// +// CartRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class CartRepositoryImpl: CartRepository { + private let api: CartAPI + private let preferences: AuthPreferences + + init(api: CartAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + func getCartList() async throws -> CartList { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let data: [CartDto] = try await api.getCartList(agencyId: user.agencyId) + let cartItems = data.map { $0.toModel() } + return CartList(items: cartItems) + } + + func addCart(partId: Int, quantity: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let request = AddCartRequestDto(partId: partId, quantity: quantity) + try await api.addCart(agencyId: user.agencyId, request: request) + } + + func deleteCart(cartItemId: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + try await api.deleteCart(agencyId: user.agencyId, cartItemId: cartItemId) + } + + func deleteAllCart() async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + try await api.deleteAllCart(agencyId: user.agencyId) + } + + func updateCartQuantity(cartItemId: Int, quantity: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let request = UpdateCartRequestDto(quantity: quantity) + try await api.updateCart(agencyId: user.agencyId, cartItemId: cartItemId, request: request) + } +} + diff --git a/SampoomManagement/Features/Cart/Domain/Models/Cart+Subtotal.swift b/SampoomManagement/Features/Cart/Domain/Models/Cart+Subtotal.swift new file mode 100644 index 0000000..ecc682c --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Models/Cart+Subtotal.swift @@ -0,0 +1,14 @@ +// +// Cart+Subtotal.swift +// SampoomManagement +// +// Adds per-item subtotal computation for cart. +// + +import Foundation + +extension CartPart { + var subtotal: Int { standardCost * quantity } +} + + diff --git a/SampoomManagement/Features/Cart/Domain/Models/Cart.swift b/SampoomManagement/Features/Cart/Domain/Models/Cart.swift new file mode 100644 index 0000000..1586565 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Models/Cart.swift @@ -0,0 +1,30 @@ +// +// Cart.swift +// SampoomManagement +// +// Created by 채상윤 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 + let standardCost: 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..b496cc2 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Models/CartList.swift @@ -0,0 +1,25 @@ +// +// CartList.swift +// SampoomManagement +// +// Created by 채상윤 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..661f555 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/Repository/CartRepository.swift @@ -0,0 +1,17 @@ +// +// CartRepository.swift +// SampoomManagement +// +// Created by 채상윤 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..ec537cd --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/AddCartUseCase.swift @@ -0,0 +1,21 @@ +// +// AddCartUseCase.swift +// SampoomManagement +// +// Created by 채상윤 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..8bfafb8 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteAllCartUseCase.swift @@ -0,0 +1,21 @@ +// +// DeleteAllCartUseCase.swift +// SampoomManagement +// +// Created by 채상윤 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..b30430f --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/DeleteCartUseCase.swift @@ -0,0 +1,21 @@ +// +// DeleteCartUseCase.swift +// SampoomManagement +// +// Created by 채상윤 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..5339e1c --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/GetCartUseCase.swift @@ -0,0 +1,21 @@ +// +// GetCartUseCase.swift +// SampoomManagement +// +// Created by 채상윤 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..f6db7d6 --- /dev/null +++ b/SampoomManagement/Features/Cart/Domain/UseCase/UpdateCartQuantityUseCase.swift @@ -0,0 +1,21 @@ +// +// UpdateCartQuantityUseCase.swift +// SampoomManagement +// +// Created by 채상윤 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..a35bbb7 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListUiEvent.swift @@ -0,0 +1,19 @@ +// +// CartListUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 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 dismissOrderResult +} + diff --git a/SampoomManagement/Features/Cart/UI/CartListUiState.swift b/SampoomManagement/Features/Cart/UI/CartListUiState.swift new file mode 100644 index 0000000..a78f761 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListUiState.swift @@ -0,0 +1,72 @@ +// +// CartListUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +struct CartListUiState { + let cartList: [Cart] + let cartLoading: Bool + let selectedCart: Cart? + let isUpdating: Bool + let isDeleting: Bool + let isOrderSuccess: Bool + let isProcessing: Bool + let processedOrder: Order? + + init( + cartList: [Cart] = [], + cartLoading: Bool = false, + selectedCart: Cart? = nil, + isUpdating: Bool = false, + isDeleting: Bool = false, + isOrderSuccess: Bool = false, + isProcessing: Bool = false, + processedOrder: Order? = nil + ) { + self.cartList = cartList + self.cartLoading = cartLoading + self.selectedCart = selectedCart + self.isUpdating = isUpdating + self.isDeleting = isDeleting + self.isOrderSuccess = isOrderSuccess + self.isProcessing = isProcessing + self.processedOrder = processedOrder + } + + func copy( + cartList: [Cart]? = nil, + cartLoading: Bool? = nil, + selectedCart: Cart? = nil, + isUpdating: Bool? = nil, + isDeleting: Bool? = nil, + isOrderSuccess: Bool? = nil, + isProcessing: Bool? = nil, + processedOrder: Order? = nil + ) -> CartListUiState { + return CartListUiState( + cartList: cartList ?? self.cartList, + cartLoading: cartLoading ?? self.cartLoading, + selectedCart: selectedCart ?? self.selectedCart, + isUpdating: isUpdating ?? self.isUpdating, + isDeleting: isDeleting ?? self.isDeleting, + isOrderSuccess: isOrderSuccess ?? self.isOrderSuccess, + isProcessing: isProcessing ?? self.isProcessing, + processedOrder: processedOrder ?? self.processedOrder + ) + } + + var totalCost: Int { + cartList.reduce(0) { acc, category in + acc + category.groups.reduce(0) { acc2, group in + acc2 + group.parts.reduce(0) { acc3, part in + acc3 + part.subtotal + } + } + } + } +} + diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift new file mode 100644 index 0000000..d1562f3 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -0,0 +1,310 @@ +// +// CartListView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI +import Combine + +struct CartListView: View { + @ObservedObject var viewModel: CartListViewModel + let dependencies: AppDependencies + @State private var showEmptyCartDialog = false + @State private var showConfirmDialog = false + + var body: some View { + // Precompute simple bindings to reduce type-checking load + let isOrderSuccess = Binding( + get: { viewModel.uiState.isOrderSuccess }, + set: { _ in } + ) + + MainNavigationContent( + shouldShowEmptyButton: shouldShowEmptyButton, + showEmptyCartDialog: $showEmptyCartDialog, + showConfirmDialog: $showConfirmDialog, + isOrderSuccessBinding: isOrderSuccess, + onEmptyAll: { viewModel.onEvent(.deleteAllCart) }, + onProcessOrder: { viewModel.onEvent(.processOrder) }, + onAppear: { viewModel.onEvent(.loadCartList) }, + cartContent: { AnyView(cartContent) }, + orderResultSheet: { + AnyView( + Group { + if let processedOrder = viewModel.uiState.processedOrder { + OrderResultBottomSheet( + order: processedOrder, + onDismiss: { + viewModel.onEvent(.dismissOrderResult) + }, + viewModel: dependencies.makeOrderDetailViewModel(orderId: processedOrder.orderId) + ) + } else { + EmptyView() + } + } + ) + }, + viewModel: viewModel + ) + } + + @ViewBuilder + private var cartContent: some View { + if viewModel.uiState.cartLoading { + loadingView + } else if viewModel.uiState.cartList.isEmpty { + emptyView + } else { + cartListView + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + HStack { + Spacer() + EmptyView(title: StringResources.Cart.emptyMessage) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var cartListView: some View { + ZStack(alignment: .bottom) { + ScrollView { + LazyVStack(spacing: 16) { + CartListContent( + categories: viewModel.uiState.cartList, + isUpdating: viewModel.uiState.isUpdating, + isDeleting: viewModel.uiState.isDeleting, + onEvent: { event in + viewModel.onEvent(event) + } + ) + Spacer() + .frame(height: 100) + } + .padding(.horizontal, 16) + } + + orderButton + } + } + + private var orderButton: some View { + VStack { + Spacer() + CommonButton("\(formatWon(viewModel.uiState.totalCost)) \(StringResources.Cart.processOrder)", backgroundColor: .accent, textColor: .white) { + showConfirmDialog = true + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + private var shouldShowEmptyButton: Bool { + !viewModel.uiState.cartLoading && + !viewModel.uiState.cartList.isEmpty + } + +} + +private struct MainNavigationContent: View { + let shouldShowEmptyButton: Bool + @Binding var showEmptyCartDialog: Bool + @Binding var showConfirmDialog: Bool + let isOrderSuccessBinding: Binding + let onEmptyAll: () -> Void + let onProcessOrder: () -> Void + let onAppear: () -> Void + let cartContent: () -> AnyView + let orderResultSheet: () -> AnyView + @ObservedObject var viewModel: CartListViewModel + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + cartContent() + } + .navigationTitle(StringResources.Cart.title) + .navigationBarTitleDisplayMode(.automatic) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if shouldShowEmptyButton { + Button(StringResources.Cart.emptyAll) { + showEmptyCartDialog = true + } + .foregroundColor(.red) + } + } + } + .background(Color.background) + .alert(StringResources.Cart.confirmEmptyTitle, isPresented: $showEmptyCartDialog) { + Button(StringResources.Common.ok) { + onEmptyAll() + } + Button(StringResources.Common.cancel, role: .cancel) { } + } message: { + Text(StringResources.Cart.confirmEmptyMessage) + } + .alert(StringResources.Cart.confirmProcessTitle, isPresented: $showConfirmDialog) { + Button(StringResources.Common.ok) { + onProcessOrder() + } + Button(StringResources.Common.cancel, role: .cancel) { } + } message: { + Text(StringResources.Cart.confirmProcessMessage) + } + .onAppear { + onAppear() + } + .sheet(isPresented: Binding( + get: { viewModel.uiState.processedOrder != nil && viewModel.uiState.isOrderSuccess }, + set: { _ in } + ), onDismiss: { + viewModel.onEvent(.dismissOrderResult) + }) { + orderResultSheet() + } + } + } +} + +private struct CartListContent: View { + let categories: [Cart] + let isUpdating: Bool + let isDeleting: Bool + let onEvent: (CartListUiEvent) -> Void + + var body: some View { + ForEach(categories, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in + CartSection( + categoryName: category.categoryName, + groupName: group.groupName, + parts: group.parts, + isUpdating: isUpdating, + isDeleting: isDeleting, + onEvent: onEvent + ) + } + } + } +} + +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) + + // 단가 및 소계 표시 + HStack { + Spacer() + Text(formatWon(part.subtotal)) + .font(.gmarketTitle3) + .foregroundColor(.text) + } + .padding(.horizontal, 16) + .padding(.bottom, 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..518b2b9 --- /dev/null +++ b/SampoomManagement/Features/Cart/UI/CartListViewModel.swift @@ -0,0 +1,263 @@ +// +// CartListViewModel.swift +// SampoomManagement +// +// Created by 채상윤 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 + private let createOrderUseCase: CreateOrderUseCase + private let globalMessageHandler: GlobalMessageHandler + + init( + getCartUseCase: GetCartUseCase, + updateCartQuantityUseCase: UpdateCartQuantityUseCase, + deleteCartUseCase: DeleteCartUseCase, + deleteAllCartUseCase: DeleteAllCartUseCase, + createOrderUseCase: CreateOrderUseCase, + globalMessageHandler: GlobalMessageHandler + ) { + self.getCartUseCase = getCartUseCase + self.updateCartQuantityUseCase = updateCartQuantityUseCase + self.deleteCartUseCase = deleteCartUseCase + self.deleteAllCartUseCase = deleteAllCartUseCase + self.createOrderUseCase = createOrderUseCase + self.globalMessageHandler = globalMessageHandler + } + + func onEvent(_ event: CartListUiEvent) { + 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 .dismissOrderResult: + uiState = uiState.copy(isOrderSuccess: false, processedOrder: nil) + } + } + + private func loadCartList() { + Task { + await MainActor.run { + uiState = uiState.copy(cartLoading: true) + } + + do { + let cartList = try await getCartUseCase.execute() + + await MainActor.run { + uiState = uiState.copy( + cartList: cartList.items, + cartLoading: false + ) + } + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(cartLoading: false) + } + } + } + } + + private func processOrder() { + guard !uiState.isProcessing else { return } + Task { + await MainActor.run { + uiState = uiState.copy(isProcessing: true) + } + do { + let cartList = CartList(items: uiState.cartList) + let order = try await createOrderUseCase.execute(cartList: cartList) + await MainActor.run { + uiState = uiState.copy( + isOrderSuccess: true, + isProcessing: false, + processedOrder: order + ) + } + globalMessageHandler.showMessage(StringResources.Cart.orderSuccess, isError: false) + + // 로컬 상태 먼저 업데이트 (즉시 UI 반영) + await MainActor.run { + removeAllFromLocalList() + } + + // 서버 삭제 완료 후 재조회 + do { + try await deleteAllCartUseCase.execute() + loadCartList() // 주문 후 장바구니 새로고침 + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + loadCartList() // 에러 발생 시에도 재조회하여 롤백 + } + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isProcessing: false) + } + } + } + } + + private func updateQuantity(cartItemId: Int, quantity: Int) { + // 1. 로컬 상태 먼저 업데이트 (즉시 UI 반영) + updateLocalQuantity(cartItemId: cartItemId, quantity: quantity) + + // 2. 백그라운드에서 서버 동기화 + Task { + await MainActor.run { + uiState = uiState.copy(isUpdating: true) + } + + do { + try await updateCartQuantityUseCase.execute(cartItemId: cartItemId, quantity: quantity) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + } catch { + // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + } + } + } + + 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, + standardCost: part.standardCost + ) + } 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) + } + + do { + try await deleteCartUseCase.execute(cartItemId: cartItemId) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + } catch { + // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + } + } + } + + 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) + } + + do { + try await deleteAllCartUseCase.execute() + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + } catch { + // 3. 실패 시 에러 표시 후 롤백 + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isDeleting: false) + } + loadCartList() // 에러 표시 후 백그라운드에서 롤백 + } + } + } + + private func removeAllFromLocalList() { + uiState = uiState.copy(cartList: []) + } + + func clearSuccess() { + uiState = uiState.copy(isOrderSuccess: false) + } +} + diff --git a/SampoomManagement/Features/Dashboard/Data/Mappers/DashboardMappers.swift b/SampoomManagement/Features/Dashboard/Data/Mappers/DashboardMappers.swift new file mode 100644 index 0000000..ffdf8ef --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Data/Mappers/DashboardMappers.swift @@ -0,0 +1,22 @@ +import Foundation + +extension DashboardResponseDTO { + func toModel() -> Dashboard { + return Dashboard( + totalParts: totalParts, + outOfStockParts: outOfStockParts, + lowStockParts: lowStockParts, + totalQuantity: totalQuantity + ) + } +} + +extension WeeklySummaryResponseDTO { + func toModel() -> WeeklySummary { + return WeeklySummary( + inStockParts: inStockParts, + outStockParts: outStockParts, + weekPeriod: weekPeriod + ) + } +} diff --git a/SampoomManagement/Features/Dashboard/Data/Remote/API/DashboardAPI.swift b/SampoomManagement/Features/Dashboard/Data/Remote/API/DashboardAPI.swift new file mode 100644 index 0000000..e71a007 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Data/Remote/API/DashboardAPI.swift @@ -0,0 +1,28 @@ +import Foundation +import Alamofire + +class DashboardAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + func getDashboard(agencyId: Int) async throws -> APIResponse { + let endpoint = "agency/\(agencyId)/dashboard" + return try await networkManager.request( + endpoint: endpoint, + method: .get, + responseType: DashboardResponseDTO.self + ) + } + + func getWeeklySummary(agencyId: Int) async throws -> APIResponse { + let endpoint = "agency/\(agencyId)/weekly-summary" + return try await networkManager.request( + endpoint: endpoint, + method: .get, + responseType: WeeklySummaryResponseDTO.self + ) + } +} diff --git a/SampoomManagement/Features/Dashboard/Data/Remote/DTO/DashboardResponseDTO.swift b/SampoomManagement/Features/Dashboard/Data/Remote/DTO/DashboardResponseDTO.swift new file mode 100644 index 0000000..3b78ba1 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Data/Remote/DTO/DashboardResponseDTO.swift @@ -0,0 +1,8 @@ +import Foundation + +struct DashboardResponseDTO: Codable { + let totalParts: Int + let outOfStockParts: Int + let lowStockParts: Int + let totalQuantity: Int +} diff --git a/SampoomManagement/Features/Dashboard/Data/Remote/DTO/WeeklySummaryResponseDTO.swift b/SampoomManagement/Features/Dashboard/Data/Remote/DTO/WeeklySummaryResponseDTO.swift new file mode 100644 index 0000000..88060e0 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Data/Remote/DTO/WeeklySummaryResponseDTO.swift @@ -0,0 +1,7 @@ +import Foundation + +struct WeeklySummaryResponseDTO: Codable { + let inStockParts: Int + let outStockParts: Int + let weekPeriod: String +} diff --git a/SampoomManagement/Features/Dashboard/Data/Repository/DashboardRepositoryImpl.swift b/SampoomManagement/Features/Dashboard/Data/Repository/DashboardRepositoryImpl.swift new file mode 100644 index 0000000..4ef4e80 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Data/Repository/DashboardRepositoryImpl.swift @@ -0,0 +1,27 @@ +import Foundation + +class DashboardRepositoryImpl: DashboardRepository { + private let api: DashboardAPI + private let authPreferences: AuthPreferences + + init(api: DashboardAPI, authPreferences: AuthPreferences) { + self.api = api + self.authPreferences = authPreferences + } + + func getDashboard() async throws -> Dashboard { + guard let user = try authPreferences.getStoredUser() else { throw NetworkError.unauthorized } + let response = try await api.getDashboard(agencyId: user.agencyId) + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { throw NetworkError.noData } + return data.toModel() + } + + func getWeeklySummary() async throws -> WeeklySummary { + guard let user = try authPreferences.getStoredUser() else { throw NetworkError.unauthorized } + let response = try await api.getWeeklySummary(agencyId: user.agencyId) + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { throw NetworkError.noData } + return data.toModel() + } +} diff --git a/SampoomManagement/Features/Dashboard/Domain/Models/Dashboard.swift b/SampoomManagement/Features/Dashboard/Domain/Models/Dashboard.swift new file mode 100644 index 0000000..651897c --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Domain/Models/Dashboard.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Dashboard: Equatable { + let totalParts: Int + let outOfStockParts: Int + let lowStockParts: Int + let totalQuantity: Int +} diff --git a/SampoomManagement/Features/Dashboard/Domain/Models/WeeklySummary.swift b/SampoomManagement/Features/Dashboard/Domain/Models/WeeklySummary.swift new file mode 100644 index 0000000..83ad0f2 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Domain/Models/WeeklySummary.swift @@ -0,0 +1,7 @@ +import Foundation + +struct WeeklySummary: Equatable { + let inStockParts: Int + let outStockParts: Int + let weekPeriod: String +} diff --git a/SampoomManagement/Features/Dashboard/Domain/Repository/DashboardRepository.swift b/SampoomManagement/Features/Dashboard/Domain/Repository/DashboardRepository.swift new file mode 100644 index 0000000..d9c3ef6 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Domain/Repository/DashboardRepository.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol DashboardRepository { + func getDashboard() async throws -> Dashboard + func getWeeklySummary() async throws -> WeeklySummary +} diff --git a/SampoomManagement/Features/Dashboard/Domain/UseCase/GetDashboardUseCase.swift b/SampoomManagement/Features/Dashboard/Domain/UseCase/GetDashboardUseCase.swift new file mode 100644 index 0000000..57aa2a1 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Domain/UseCase/GetDashboardUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +struct GetDashboardUseCase { + private let repository: DashboardRepository + + init(repository: DashboardRepository) { + self.repository = repository + } + + func execute() async throws -> Dashboard { + return try await repository.getDashboard() + } +} diff --git a/SampoomManagement/Features/Dashboard/Domain/UseCase/GetWeeklySummaryUseCase.swift b/SampoomManagement/Features/Dashboard/Domain/UseCase/GetWeeklySummaryUseCase.swift new file mode 100644 index 0000000..92b4dac --- /dev/null +++ b/SampoomManagement/Features/Dashboard/Domain/UseCase/GetWeeklySummaryUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +struct GetWeeklySummaryUseCase { + private let repository: DashboardRepository + + init(repository: DashboardRepository) { + self.repository = repository + } + + func execute() async throws -> WeeklySummary { + return try await repository.getWeeklySummary() + } +} diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift new file mode 100644 index 0000000..19a290c --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift @@ -0,0 +1,15 @@ +// +// DashboardUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum DashboardUiEvent { + case loadDashboard + case retryDashboard +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift new file mode 100644 index 0000000..a3bde4b --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift @@ -0,0 +1,58 @@ +// +// DashboardUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct DashboardUiState: Equatable { + let orderList: [Order] + let dashboard: Dashboard? + let weeklySummary: WeeklySummary? + let dashboardLoading: Bool + let dashboardError: String? + let weeklySummaryLoading: Bool + let weeklySummaryError: String? + + init( + orderList: [Order] = [], + dashboard: Dashboard? = nil, + weeklySummary: WeeklySummary? = nil, + dashboardLoading: Bool = false, + dashboardError: String? = nil, + weeklySummaryLoading: Bool = false, + weeklySummaryError: String? = nil + ) { + self.orderList = orderList + self.dashboard = dashboard + self.weeklySummary = weeklySummary + self.dashboardLoading = dashboardLoading + self.dashboardError = dashboardError + self.weeklySummaryLoading = weeklySummaryLoading + self.weeklySummaryError = weeklySummaryError + } + + func copy( + orderList: [Order]? = nil, + dashboard: Dashboard?? = nil, + weeklySummary: WeeklySummary?? = nil, + dashboardLoading: Bool? = nil, + dashboardError: String?? = nil, + weeklySummaryLoading: Bool? = nil, + weeklySummaryError: String?? = nil + ) -> DashboardUiState { + return DashboardUiState( + orderList: orderList ?? self.orderList, + dashboard: dashboard ?? self.dashboard, + weeklySummary: weeklySummary ?? self.weeklySummary, + dashboardLoading: dashboardLoading ?? self.dashboardLoading, + dashboardError: dashboardError ?? self.dashboardError, + weeklySummaryLoading: weeklySummaryLoading ?? self.weeklySummaryLoading, + weeklySummaryError: weeklySummaryError ?? self.weeklySummaryError + ) + } +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift new file mode 100644 index 0000000..d8e5ceb --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -0,0 +1,195 @@ +// +// DashboardView.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct DashboardView: View { + @ObservedObject var viewModel: DashboardViewModel + let onLogoutClick: () -> Void + let onNavigateOrderDetail: (Order) -> Void + let onNavigateOrderList: () -> Void + let onSettingClick: () -> Void + let userName: String + let branch: String + let userRole: UserRole + + var body: some View { + VStack(spacing: 0) { + // Top bar + HStack { + Image("oneline_logo") + .resizable() + .scaledToFit() + .frame(height: 24) + Spacer() + HStack(spacing: 12) { + if userRole.isAdmin { + Button(action: {}) { + Image("employee").renderingMode(.template).foregroundStyle(.text) + } + } + Button(action: onSettingClick) { + Image("settings").renderingMode(.template).foregroundStyle(.text) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + + ScrollView { + VStack(spacing: 16) { + titleSection + buttonSection + orderListSection + weeklySummarySection + Spacer(minLength: 100) + } + .padding(.horizontal, 16) + } + } + .background(Color.background) + .refreshable { + viewModel.onEvent(.loadDashboard) + } + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("\(branch)") + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.text) + + Group { + Text(StringResources.Dashboard.greetingPrefix) + Text(userName).foregroundColor(.accent) + Text(StringResources.Dashboard.greetingSuffix) + } + .font(.gmarketTitle) + .fontWeight(.bold) + .foregroundColor(.text) + + Text(StringResources.Dashboard.intro) + .font(.gmarketBody) + .foregroundColor(.text) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 24) + } + + private var buttonSection: some View { + let dash = viewModel.uiState.dashboard + return VStack(spacing: 16) { + if userRole.isAdmin { + buttonCard(iconName: "employee", valueText: "45", subText: StringResources.Dashboard.employee, bordered: true) {} + } + HStack(spacing: 16) { + buttonCard(iconName: "car", valueText: String(dash?.totalParts ?? 0), subText: StringResources.Dashboard.partsOnHand) {} + buttonCard(iconName: "block", valueText: String(dash?.outOfStockParts ?? 0), subText: StringResources.Dashboard.shortageOfParts) {} + } + HStack(spacing: 16) { + buttonCard(iconName: "warning", valueText: String(dash?.lowStockParts ?? 0), subText: StringResources.Dashboard.shortageOfParts) {} + buttonCard(iconName: "parts", valueText: String(dash?.totalQuantity ?? 0), subText: StringResources.Dashboard.partsOnHand) {} + } + } + .padding(.bottom, 16) + } + + private func buttonCard(iconName: String, valueText: String, subText: String, bordered: Bool = false, onClick: @escaping () -> Void) -> some View { + Button(action: onClick) { + VStack(alignment: .center, spacing: 16) { + Image(iconName) + .renderingMode(.template) + .foregroundStyle(Color.white) + .padding(8) + .background(.accent) + .clipShape(Circle()) + Text(valueText) + .font(.gmarketTitle2) + .foregroundColor(.text) + Text(subText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(Color.backgroundCard) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(bordered ? Color.accentColor : Color.clear, lineWidth: 1) + ) + } + } + + private var orderListSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(StringResources.Dashboard.recentOrdersTitle) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.text) + Spacer() + Button(action: { onNavigateOrderList() }) { + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + .padding(8) + } + } + + if viewModel.uiState.orderList.isEmpty { + VStack { Text(StringResources.Order.emptyList).foregroundColor(.textSecondary) } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else { + VStack(spacing: 8) { + ForEach(viewModel.uiState.orderList, id: \.orderId) { order in + OrderItem(order: order) { onNavigateOrderDetail(order) } + } + } + } + } + } + + private var weeklySummarySection: some View { + let weekly = viewModel.uiState.weeklySummary + return VStack(alignment: .leading, spacing: 16) { + Text(StringResources.Dashboard.weeklySummaryTitle) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.text) + + HStack(spacing: 0) { + VStack(spacing: 8) { + Text(String(weekly?.inStockParts ?? 0)) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.green) + Text(StringResources.Dashboard.weeklySummaryInStock) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + + VStack(spacing: 8) { + Text(String(weekly?.outStockParts ?? 0)) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.red) + Text(StringResources.Dashboard.weeklySummaryOutStock) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity) + .padding(16) + .background(Color.backgroundCard) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift new file mode 100644 index 0000000..2de2522 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift @@ -0,0 +1,98 @@ +// +// DashboardViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class DashboardViewModel: ObservableObject { + @Published var uiState = DashboardUiState() + + private let getOrderUseCase: GetOrderUseCase + private let getDashboardUseCase: GetDashboardUseCase + private let getWeeklySummaryUseCase: GetWeeklySummaryUseCase + private let messageHandler: GlobalMessageHandler + + init( + getOrderUseCase: GetOrderUseCase, + getDashboardUseCase: GetDashboardUseCase, + getWeeklySummaryUseCase: GetWeeklySummaryUseCase, + messageHandler: GlobalMessageHandler + ) { + self.getOrderUseCase = getOrderUseCase + self.getDashboardUseCase = getDashboardUseCase + self.getWeeklySummaryUseCase = getWeeklySummaryUseCase + self.messageHandler = messageHandler + loadAll() + } + + func onEvent(_ event: DashboardUiEvent) { + switch event { + case .loadDashboard, .retryDashboard: + loadAll() + } + } + + private func loadAll() { + loadOrderList() + Task { await loadDashboard() } + Task { await loadWeeklySummary() } + } + + private func loadOrderList() { + Task { + do { + let orderList = try await getOrderUseCase.execute() + uiState = uiState.copy( + orderList: Array(orderList.items.prefix(5)), + dashboardLoading: false + ) + } catch { + messageHandler.showMessage(error.localizedDescription, isError: true) + } + } + } + + private func loadDashboard() async { + uiState = uiState.copy(dashboardLoading: true, dashboardError: .some(nil)) + do { + let dashboard = try await getDashboardUseCase.execute() + uiState = uiState.copy( + dashboard: dashboard, + dashboardLoading: false, + dashboardError: .some(nil) + ) + } catch { + messageHandler.showMessage(error.localizedDescription, isError: true) + uiState = uiState.copy( + dashboardLoading: false, + dashboardError: .some(error.localizedDescription) + ) + } + } + + private func loadWeeklySummary() async { + uiState = uiState.copy(weeklySummaryLoading: true, weeklySummaryError: .some(nil)) + do { + let weekly = try await getWeeklySummaryUseCase.execute() + uiState = uiState.copy( + weeklySummary: weekly, + weeklySummaryLoading: false, + weeklySummaryError: .some(nil) + ) + } catch { + messageHandler.showMessage(error.localizedDescription, isError: true) + uiState = uiState.copy( + weeklySummaryLoading: false, + weeklySummaryError: .some(error.localizedDescription) + ) + } + } +} + + diff --git a/SampoomManagement/Features/Order/Data/Mappers/OrderMappers .swift b/SampoomManagement/Features/Order/Data/Mappers/OrderMappers .swift new file mode 100644 index 0000000..9f20fb5 --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Mappers/OrderMappers .swift @@ -0,0 +1,32 @@ +// +// OrderMappers .swift +// SampoomManagement +// +// Created by 채상윤 on 10/21/25. +// + +import Foundation + +extension OrderDto { + func toModel() -> Order { + return Order(orderId: orderId, orderNumber: orderNumber, createdAt: createdAt, status: status, agencyName: agencyName, items: items.map { $0.toModel() }) + } +} + +extension OrderCategoryDto { + func toModel() -> OrderCategory { + return OrderCategory(categoryId: categoryId, categoryName: categoryName, groups: groups.map { $0.toModel() }) + } +} + +extension OrderGroupDto { + func toModel() -> OrderGroup { + return OrderGroup(groupId: groupId, groupName: groupName, parts: parts.map { $0.toModel() }) + } +} + +extension OrderPartDto { + func toModel() -> OrderPart { + return OrderPart(partId: partId, code: code, name: name, quantity: quantity, standardCost: standardCost) + } +} diff --git a/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift new file mode 100644 index 0000000..078c24d --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/API/OrderAPI.swift @@ -0,0 +1,92 @@ +// +// OrderAPI.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation +import Alamofire + +class OrderAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + /// 주문 목록 조회 + func getOrderList(agencyName: String, page: Int = 0, size: Int = 20) async throws -> OrderListDto { + let encodedAgencyName = agencyName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? agencyName + let endpoint = "order/requested?from=\(encodedAgencyName)&page=\(page)&size=\(size)" + + let response: APIResponse = try await networkManager.request( + endpoint: endpoint, + method: .get, + responseType: OrderListDto.self + ) + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + + return response.data ?? OrderListDto(content: [], totalElements: 0, totalPages: 0, number: 0, last: true, size: size, first: true) + } + + /// 주문 생성 + func createOrder(orderRequestDto: OrderRequestDto) async throws -> OrderDto { + let response: APIResponse = try await networkManager.request( + endpoint: "order/", + method: .post, + body: orderRequestDto, + responseType: OrderDto.self + ) + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { + throw NetworkError.noData + } + return data + } + + /// 주문 완료 처리 + func completeOrder(orderId: Int) async throws { + let response: APIResponse = try await networkManager.request( + endpoint: "order/complete/\(orderId)", + method: .patch, + responseType: EmptyResponse.self + ) + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } + } + + /// 주문 입고 처리 (대리점) - 재고 반영 + func receiveOrder(agencyId: Int, body: ReceiveStockRequestDto) async throws { + let response: APIResponse = try await networkManager.request( + endpoint: "agency/\(agencyId)/stock", + method: .patch, + body: body, + responseType: EmptyResponse.self + ) + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } + } + + /// 주문 상세 조회 + func getOrderDetail(orderId: Int) async throws -> OrderDto { + let response: APIResponse = try await networkManager.request( + endpoint: "order/\(orderId)", + method: .get, + responseType: OrderDto.self + ) + guard response.success else { throw NetworkError.serverError(response.status, message: response.message) } + guard let data = response.data else { + throw NetworkError.noData + } + return data + } + + /// 주문 취소 + func cancelOrder(orderId: Int) async throws { + let response: APIResponse = try await networkManager.request( + endpoint: "order/cancel/\(orderId)", + method: .patch, + responseType: EmptyResponse.self + ) + if !response.success { throw NetworkError.serverError(response.status, message: response.message) } + } +} diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderDto.swift new file mode 100644 index 0000000..ef41e9e --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderDto.swift @@ -0,0 +1,38 @@ +// +// OrderDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +/// 주문 DTO +struct OrderDto: Codable { + let orderId: Int + let orderNumber: String? + let createdAt: String? + let status: OrderStatus + let agencyName: String? + let items: [OrderCategoryDto] +} + +struct OrderCategoryDto: Codable { + let categoryId: Int + let categoryName: String + let groups: [OrderGroupDto] +} + +struct OrderGroupDto: Codable { + let groupId: Int + let groupName: String + let parts: [OrderPartDto] +} + +struct OrderPartDto: Codable { + let partId: Int + let code: String + let name: String + let quantity: Int + let standardCost: Int +} diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift new file mode 100644 index 0000000..e5d7e66 --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderListDto.swift @@ -0,0 +1,20 @@ +// +// OrderListDto.swift +// SampoomManagement +// +// Created by 채상윤 on 11/1/25. +// + +import Foundation + +/// 주문 목록 페이징 DTO +struct OrderListDto: Codable { + let content: [OrderDto] + let totalElements: Int + let totalPages: Int + let number: Int // 현재 페이지 번호 + let last: Bool // 마지막 페이지 여부 + let size: Int + let first: Bool +} + diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift new file mode 100644 index 0000000..27ff1e9 --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/OrderRequestDto.swift @@ -0,0 +1,16 @@ +// +// OrderRequestDto.swift +// SampoomManagement +// +// Created by 채상윤 on 10/31/25. +// + +import Foundation + +/// 주문 생성 요청 DTO +struct OrderRequestDto: Codable { + let agencyName: String + let items: [OrderCategoryDto] +} + + diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockItemDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockItemDto.swift new file mode 100644 index 0000000..56c9a2c --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockItemDto.swift @@ -0,0 +1,13 @@ +// +// ReceiveStockItemDto.swift +// SampoomManagement +// + +import Foundation + +struct ReceiveStockItemDto: Codable { + let partId: Int + let quantity: Int +} + + diff --git a/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockRequestDto.swift b/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockRequestDto.swift new file mode 100644 index 0000000..1be770e --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Remote/DTO/ReceiveStockRequestDto.swift @@ -0,0 +1,12 @@ +// +// ReceiveStockRequestDto.swift +// SampoomManagement +// + +import Foundation + +struct ReceiveStockRequestDto: Codable { + let items: [ReceiveStockItemDto] +} + + diff --git a/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift new file mode 100644 index 0000000..5884716 --- /dev/null +++ b/SampoomManagement/Features/Order/Data/Repository/OrderRepositoryImpl.swift @@ -0,0 +1,91 @@ +// +// OrderRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class OrderRepositoryImpl: OrderRepository { + private let api: OrderAPI + private let preferences: AuthPreferences + + init(api: OrderAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + func getOrderList(page: Int, size: Int) async throws -> (items: [Order], hasMore: Bool) { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let dto = try await api.getOrderList(agencyName: user.branch, page: page, size: size) + let orders = dto.content.map { $0.toModel() } + // last가 false면 더 많은 페이지가 있음 + let hasMore = !dto.last + return (items: orders, hasMore: hasMore) + } + + func createOrder(cartList: CartList) async throws -> Order { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let items = cartList.items.map { cart in + OrderCategoryDto( + categoryId: cart.categoryId, + categoryName: cart.categoryName, + groups: cart.groups.map { group in + OrderGroupDto( + groupId: group.groupId, + groupName: group.groupName, + parts: group.parts.map { part in + OrderPartDto( + partId: part.partId, + code: part.code, + name: part.name, + quantity: part.quantity, + standardCost: part.standardCost + ) + } + ) + } + ) + } + let request = OrderRequestDto( + agencyName: user.branch, + items: items + ) + let dto = try await api.createOrder(orderRequestDto: request) + return dto.toModel() + } + + func completeOrder(orderId: Int) async throws { + // 인증 확인 (다른 상태 변경 메서드와 동일한 패턴) + guard (try preferences.getStoredUser()) != nil else { + throw NetworkError.unauthorized + } + try await api.completeOrder(orderId: orderId) + } + + func receiveOrder(items: [(Int, Int)]) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let body = ReceiveStockRequestDto( + items: items.map { (partId, quantity) in + ReceiveStockItemDto(partId: partId, quantity: quantity) + } + ) + try await api.receiveOrder(agencyId: user.agencyId, body: body) + } + + func getOrderDetail(orderId: Int) async throws -> Order { + let dto = try await api.getOrderDetail(orderId: orderId) + return dto.toModel() + } + + func cancelOrder(orderId: Int) async throws { + try await api.cancelOrder(orderId: orderId) + } +} diff --git a/SampoomManagement/Features/Order/Domain/Models/Order+Totals.swift b/SampoomManagement/Features/Order/Domain/Models/Order+Totals.swift new file mode 100644 index 0000000..2a5adfa --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/Models/Order+Totals.swift @@ -0,0 +1,26 @@ +// +// Order+Totals.swift +// SampoomManagement +// +// Adds per-item subtotal and order total cost computations. +// + +import Foundation + +extension OrderPart { + var subtotal: Int { standardCost * quantity } +} + +extension Order { + var totalCost: Int { + items.reduce(0) { acc, category in + acc + category.groups.reduce(0) { acc2, group in + acc2 + group.parts.reduce(0) { acc3, part in + acc3 + part.subtotal + } + } + } + } +} + + diff --git a/SampoomManagement/Features/Order/Domain/Models/Order.swift b/SampoomManagement/Features/Order/Domain/Models/Order.swift new file mode 100644 index 0000000..b0718af --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/Models/Order.swift @@ -0,0 +1,37 @@ +// +// Order.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +struct Order: Equatable { + let orderId: Int + let orderNumber: String? + let createdAt: String? + let status: OrderStatus + let agencyName: String? + let items: [OrderCategory] +} + +struct OrderCategory: Equatable { + let categoryId: Int + let categoryName: String + let groups: [OrderGroup] +} + +struct OrderGroup: Equatable { + let groupId: Int + let groupName: String + let parts: [OrderPart] +} + +struct OrderPart: Equatable { + let partId: Int + let code: String + let name: String + let quantity: Int + let standardCost: Int +} diff --git a/SampoomManagement/Features/Order/Domain/Models/OrderList.swift b/SampoomManagement/Features/Order/Domain/Models/OrderList.swift new file mode 100644 index 0000000..75d4e35 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/Models/OrderList.swift @@ -0,0 +1,26 @@ +// +// OrderList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +/// 주문 목록 모델 +struct OrderList { + let items: [Order] + let totalCount: Int + let isEmpty: Bool + + init(items: [Order] = []) { + self.items = items + self.totalCount = items.count + self.isEmpty = items.isEmpty + } + + /// 빈 주문 목록 생성 + static func empty() -> OrderList { + return OrderList() + } +} diff --git a/SampoomManagement/Features/Order/Domain/Models/OrderStatus.swift b/SampoomManagement/Features/Order/Domain/Models/OrderStatus.swift new file mode 100644 index 0000000..bc2f3f8 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/Models/OrderStatus.swift @@ -0,0 +1,44 @@ +// +// OrderStatus.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +enum OrderStatus: String, CaseIterable, Codable { + case pending = "PENDING" + case confirmed = "CONFIRMED" + case shipping = "SHIPPING" + case delayed = "DELAYED" + case producing = "PRODUCING" + case arrived = "ARRIVED" + case completed = "COMPLETED" + case canceled = "CANCELED" + + static func from(_ rawValue: String?) -> OrderStatus { + guard let rawValue = rawValue?.uppercased() else { return .pending } + + switch rawValue { + case "PENDING": + return .pending + case "CONFIRMED": + return .confirmed + case "SHIPPING": + return .shipping + case "DELAYED": + return .delayed + case "PRODUCING": + return .producing + case "ARRIVED": + return .arrived + case "COMPLETED": + return .completed + case "CANCELED": + return .canceled + default: + return .pending + } + } +} diff --git a/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift new file mode 100644 index 0000000..ec086f8 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/Repository/OrderRepository.swift @@ -0,0 +1,17 @@ +// +// OrderRepository.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +protocol OrderRepository { + func getOrderList(page: Int, size: Int) async throws -> (items: [Order], hasMore: Bool) + func createOrder(cartList: CartList) async throws -> Order + func completeOrder(orderId: Int) async throws + func receiveOrder(items: [(Int, Int)]) async throws + func getOrderDetail(orderId: Int) async throws -> Order + func cancelOrder(orderId: Int) async throws +} diff --git a/SampoomManagement/Features/Order/Domain/UseCase/CancelOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/CancelOrderUseCase.swift new file mode 100644 index 0000000..605a15a --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/CancelOrderUseCase.swift @@ -0,0 +1,20 @@ +// +// CancelOrderUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class CancelOrderUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(orderId: Int) async throws { + try await repository.cancelOrder(orderId: orderId) + } +} diff --git a/SampoomManagement/Features/Order/Domain/UseCase/CompleteOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/CompleteOrderUseCase.swift new file mode 100644 index 0000000..2270e2b --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/CompleteOrderUseCase.swift @@ -0,0 +1,21 @@ +// +// CompleteOrderUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class CompleteOrderUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(orderId: Int) async throws { + try await repository.completeOrder(orderId: orderId) + } +} + diff --git a/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift new file mode 100644 index 0000000..a5157a9 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/CreateOrderUseCase.swift @@ -0,0 +1,20 @@ +// +// CreateOrderUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class CreateOrderUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(cartList: CartList) async throws -> Order { + return try await repository.createOrder(cartList: cartList) + } +} diff --git a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift new file mode 100644 index 0000000..e7aed46 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderDetailUseCase.swift @@ -0,0 +1,20 @@ +// +// GetOrderDetailUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class GetOrderDetailUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(orderId: Int) async throws -> Order { + return try await repository.getOrderDetail(orderId: orderId) + } +} diff --git a/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift new file mode 100644 index 0000000..3356578 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/GetOrderUseCase.swift @@ -0,0 +1,20 @@ +// +// GetOrderUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class GetOrderUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(page: Int = 0, size: Int = 20) async throws -> (items: [Order], hasMore: Bool) { + return try await repository.getOrderList(page: page, size: size) + } +} diff --git a/SampoomManagement/Features/Order/Domain/UseCase/ReceiveOrderUseCase.swift b/SampoomManagement/Features/Order/Domain/UseCase/ReceiveOrderUseCase.swift new file mode 100644 index 0000000..bb46f85 --- /dev/null +++ b/SampoomManagement/Features/Order/Domain/UseCase/ReceiveOrderUseCase.swift @@ -0,0 +1,20 @@ +// +// ReceiveOrderUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +class ReceiveOrderUseCase { + private let repository: OrderRepository + + init(repository: OrderRepository) { + self.repository = repository + } + + func execute(items: [(Int, Int)]) async throws { + try await repository.receiveOrder(items: items) + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderDetailContent.swift b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift new file mode 100644 index 0000000..60c5879 --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift @@ -0,0 +1,185 @@ +// +// OrderDetailContent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI +import Combine + +struct OrderDetailContent: View { + let order: Order + + var body: some View { + ScrollView { + LazyVStack(spacing: 16) { + OrderInfoCard( + order: order + ) + + Text(StringResources.Order.detailOrderItemsTitle) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(order.items, id: \.categoryId) { category in + ForEach(category.groups, id: \.groupId) { group in + OrderSection( + categoryName: category.categoryName, + groupName: group.groupName, + parts: group.parts + ) + } + } + + Spacer() + .frame(height: 100) + } + .padding(.horizontal, 16) + } + } +} + +struct OrderInfoCard: View { + let order: Order + + var body: some View { + VStack(spacing: 0) { + OrderInfoRow( + label: StringResources.Order.detailOrderNumber, + value: order.orderNumber ?? "-" + ) + + Divider() + .padding(.horizontal, 16) + + OrderInfoRow( + label: StringResources.Order.detailOrderDate, + value: order.createdAt.map { DateFormatterUtil.formatDate($0) } ?? "-" + ) + + Divider() + .padding(.horizontal, 16) + + OrderInfoRow( + label: StringResources.Order.detailOrderAgency, + value: order.agencyName ?? "-" + ) + + Divider() + .padding(.horizontal, 16) + + HStack { + Text(StringResources.Order.detailOrderStatus) + .font(.gmarketBody) + .foregroundColor(Color("TextSecondary")) + + Spacer() + + StatusChip(status: order.status) + } + .padding(16) + + Divider() + .padding(.horizontal, 16) + + HStack { + Text(StringResources.Order.detailTotalAmount) + .font(.gmarketBody) + .foregroundColor(Color("TextSecondary")) + Spacer() + Text(formatWon(order.totalCost)) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + } + .padding(16) + } + .background(Color("Background_Card")) + .cornerRadius(12) + } +} + +struct OrderInfoRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(.gmarketBody) + .foregroundColor(Color("TextSecondary")) + + Spacer() + + Text(value) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + } + .padding(16) + } +} + +struct OrderSection: View { + let categoryName: String + let groupName: String + let parts: [OrderPart] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(categoryName) > \(groupName)") + .font(.gmarketTitle3) + .foregroundColor(Color("Text")) + + ForEach(parts, id: \.partId) { part in + OrderPartItem(part: part) + } + } + } +} + +struct OrderPartItem: View { + let part: OrderPart + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(Color("Text")) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(Color("TextSecondary")) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(formatWon(part.standardCost)) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + Text("x \(part.quantity)") + .font(.gmarketBody) + .foregroundColor(Color("Text")) + } + } + .padding(16) + + Divider() + .padding(.horizontal, 16) + + HStack { + Spacer() + Text(formatWon(part.subtotal)) + .font(.gmarketTitle3) + .foregroundColor(Color("Text")) + } + .padding(16) + } + .background(Color("Background_Card")) + .cornerRadius(12) + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift b/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift new file mode 100644 index 0000000..341f913 --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderDetailUiEvent.swift @@ -0,0 +1,15 @@ +// +// OrderDetailUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +enum OrderDetailUiEvent { + case loadOrder + case retryOrder + case receiveOrder + case cancelOrder +} diff --git a/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift b/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift new file mode 100644 index 0000000..edbf701 --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderDetailUiState.swift @@ -0,0 +1,46 @@ +// +// OrderDetailUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +struct OrderDetailUiState { + let orderDetail: Order? + let orderDetailLoading: Bool + let isProcessing: Bool + let isProcessingCancelSuccess: Bool + let isProcessingReceiveSuccess: Bool + + init( + orderDetail: Order? = nil, + orderDetailLoading: Bool = false, + isProcessing: Bool = false, + isProcessingCancelSuccess: Bool = false, + isProcessingReceiveSuccess: Bool = false + ) { + self.orderDetail = orderDetail + self.orderDetailLoading = orderDetailLoading + self.isProcessing = isProcessing + self.isProcessingCancelSuccess = isProcessingCancelSuccess + self.isProcessingReceiveSuccess = isProcessingReceiveSuccess + } + + func copy( + orderDetail: Order? = nil, + orderDetailLoading: Bool? = nil, + isProcessing: Bool? = nil, + isProcessingCancelSuccess: Bool? = nil, + isProcessingReceiveSuccess: Bool? = nil + ) -> OrderDetailUiState { + return OrderDetailUiState( + orderDetail: orderDetail ?? self.orderDetail, + orderDetailLoading: orderDetailLoading ?? self.orderDetailLoading, + isProcessing: isProcessing ?? self.isProcessing, + isProcessingCancelSuccess: isProcessingCancelSuccess ?? self.isProcessingCancelSuccess, + isProcessingReceiveSuccess: isProcessingReceiveSuccess ?? self.isProcessingReceiveSuccess + ) + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderDetailView.swift b/SampoomManagement/Features/Order/UI/OrderDetailView.swift new file mode 100644 index 0000000..0f9ea20 --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderDetailView.swift @@ -0,0 +1,119 @@ +// +// OrderDetailView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI + +struct OrderDetailView: View { + let orderId: Int + @ObservedObject var viewModel: OrderDetailViewModel + @Environment(\.dismiss) private var dismiss + + @State private var showCancelDialog = false + @State private var showReceiveDialog = false + + // Extracted content to reduce type-checking complexity + @ViewBuilder + private var contentSection: some View { + if viewModel.uiState.orderDetailLoading { + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let order = viewModel.uiState.orderDetail { + OrderDetailContent( + order: order + ) + } else { + HStack { + Spacer() + EmptyView(title: StringResources.Order.emptyList) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var bottomButtons: some View { + if hasOrderDetail { + HStack(spacing: 16) { + CommonButton( + StringResources.Order.detailOrderCancel, + isEnabled: !cannotPerformAction, + backgroundColor: Color(.failRed), + textColor: .white + ) { + showCancelDialog = true + } + + CommonButton( + StringResources.Order.detailOrderReceive, + isEnabled: canReceiveOrder, + backgroundColor: .accent, + textColor: .white + ) { + showReceiveDialog = true + } + } + .padding(16) + } + } + + private var hasOrderDetail: Bool { viewModel.uiState.orderDetail != nil } + + var body: some View { + ZStack(alignment: .bottom) { + // Content + contentSection + + // Bottom Buttons + bottomButtons + } + .navigationTitle(StringResources.Order.detailTitle) + .navigationBarTitleDisplayMode(.automatic) + .background(Color.background) + .alert(StringResources.Order.detailDialogOrderCancel, isPresented: $showCancelDialog) { + Button(StringResources.Common.ok) { + viewModel.onEvent(.cancelOrder) + } + Button(StringResources.Common.cancel, role: .cancel) { } + } + .alert(StringResources.Order.detailDialogOrderReceive, isPresented: $showReceiveDialog) { + Button(StringResources.Common.ok) { + viewModel.onEvent(.receiveOrder) + } + Button(StringResources.Common.cancel, role: .cancel) { } + } + .onAppear { + viewModel.onEvent(.loadOrder) + } + .onChange(of: viewModel.uiState.isProcessingCancelSuccess) { _, newValue in + if newValue { + viewModel.clearSuccess() + } + } + .onChange(of: viewModel.uiState.isProcessingReceiveSuccess) { _, newValue in + if newValue { + viewModel.clearSuccess() + } + } + } + + private var cannotPerformAction: Bool { + guard let order = viewModel.uiState.orderDetail else { return true } + return order.status == .completed || order.status == .canceled || viewModel.uiState.isProcessing + } + + private var canReceiveOrder: Bool { + guard let order = viewModel.uiState.orderDetail else { return false } + return order.status == .arrived && !viewModel.uiState.isProcessing + } +} + diff --git a/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift b/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift new file mode 100644 index 0000000..8a823e5 --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderDetailViewModel.swift @@ -0,0 +1,135 @@ +// +// OrderDetailViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class OrderDetailViewModel: ObservableObject { + @Published var uiState = OrderDetailUiState() + + private let getOrderDetailUseCase: GetOrderDetailUseCase + private let cancelOrderUseCase: CancelOrderUseCase + private let completeOrderUseCase: CompleteOrderUseCase + private let receiveOrderUseCase: ReceiveOrderUseCase + private let globalMessageHandler: GlobalMessageHandler + + private var orderId: Int = 0 + + init( + getOrderDetailUseCase: GetOrderDetailUseCase, + cancelOrderUseCase: CancelOrderUseCase, + completeOrderUseCase: CompleteOrderUseCase, + receiveOrderUseCase: ReceiveOrderUseCase, + globalMessageHandler: GlobalMessageHandler, + orderId: Int = 0 + ) { + self.getOrderDetailUseCase = getOrderDetailUseCase + self.cancelOrderUseCase = cancelOrderUseCase + self.completeOrderUseCase = completeOrderUseCase + self.receiveOrderUseCase = receiveOrderUseCase + self.globalMessageHandler = globalMessageHandler + self.orderId = orderId + } + + func setOrderId(_ orderId: Int) { + print("OrderDetailViewModel - SETorderId : \(orderId)") + self.orderId = orderId + } + + func onEvent(_ event: OrderDetailUiEvent) { + switch event { + case .loadOrder, .retryOrder: + loadOrderDetail() + case .receiveOrder: + receiveOrder() + case .cancelOrder: + cancelOrder() + } + } + + func clearSuccess() { + uiState = uiState.copy( + isProcessingCancelSuccess: false, + isProcessingReceiveSuccess: false + ) + } + + private func loadOrderDetail() { + Task { + uiState = uiState.copy(orderDetailLoading: true) + + do { + let order = try await getOrderDetailUseCase.execute(orderId: orderId) + uiState = uiState.copy( + orderDetail: order, + orderDetailLoading: false + ) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(orderDetailLoading: false) + } + print("OrderDetailViewModel - loadOrderDetail: \(uiState)") + } + } + + private func cancelOrder() { + Task { + print("OrderDetailViewModel - orderId : \(orderId)") + uiState = uiState.copy(isProcessing: true) + + do { + try await cancelOrderUseCase.execute(orderId: orderId) + globalMessageHandler.showMessage(StringResources.Order.detailToastOrderCancel, isError: false) + uiState = uiState.copy( + isProcessing: false, + isProcessingCancelSuccess: true + ) + loadOrderDetail() + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(isProcessing: false) + } + print("OrderDetailViewModel - cancelOrder: \(uiState)") + } + } + + private func receiveOrder() { + Task { + uiState = uiState.copy(isProcessing: true) + + do { + // 1단계: 주문 완료 처리 + try await completeOrderUseCase.execute(orderId: orderId) + + // 2단계: 재고 입고 처리 (파트별 수량 목록 생성) + guard let order = uiState.orderDetail else { throw NetworkError.noData } + let items: [(Int, Int)] = order.items.flatMap { category in + category.groups.flatMap { group in + group.parts.map { part in (part.partId, part.quantity) } + } + } + try await receiveOrderUseCase.execute(items: items) + + globalMessageHandler.showMessage(StringResources.Order.detailToastOrderReceive, isError: false) + uiState = uiState.copy( + isProcessing: false, + isProcessingReceiveSuccess: true + ) + loadOrderDetail() + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy(isProcessing: false) + } + print("OrderDetailViewModel - receiveOrder: \(uiState)") + } + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift b/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift new file mode 100644 index 0000000..476ad4d --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderListUiEvent.swift @@ -0,0 +1,14 @@ +// +// OrderListUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +enum OrderListUiEvent { + case loadOrderList + case retryOrderList + case loadMore +} diff --git a/SampoomManagement/Features/Order/UI/OrderListUiState.swift b/SampoomManagement/Features/Order/UI/OrderListUiState.swift new file mode 100644 index 0000000..525b46b --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderListUiState.swift @@ -0,0 +1,46 @@ +// +// OrderListUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation + +struct OrderListUiState { + let orderList: [Order] + let orderLoading: Bool + let hasMore: Bool + let currentPage: Int + let isLoadingMore: Bool + + init( + orderList: [Order] = [], + orderLoading: Bool = false, + hasMore: Bool = true, + currentPage: Int = 0, + isLoadingMore: Bool = false + ) { + self.orderList = orderList + self.orderLoading = orderLoading + self.hasMore = hasMore + self.currentPage = currentPage + self.isLoadingMore = isLoadingMore + } + + func copy( + orderList: [Order]? = nil, + orderLoading: Bool? = nil, + hasMore: Bool? = nil, + currentPage: Int? = nil, + isLoadingMore: Bool? = nil + ) -> OrderListUiState { + return OrderListUiState( + orderList: orderList ?? self.orderList, + orderLoading: orderLoading ?? self.orderLoading, + hasMore: hasMore ?? self.hasMore, + currentPage: currentPage ?? self.currentPage, + isLoadingMore: isLoadingMore ?? self.isLoadingMore + ) + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderListView.swift b/SampoomManagement/Features/Order/UI/OrderListView.swift new file mode 100644 index 0000000..0c99cba --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderListView.swift @@ -0,0 +1,116 @@ +// +// OrderListView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI +import Combine + +struct OrderListView: View { + @ObservedObject var viewModel: OrderListViewModel + let onNavigateOrderDetail: (Int) -> Void + + var body: some View { + VStack(spacing: 0) { + // Content + if viewModel.uiState.orderLoading && viewModel.uiState.orderList.isEmpty { + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.uiState.orderList.isEmpty && !viewModel.uiState.orderLoading { + HStack { + Spacer() + EmptyView(title: StringResources.Order.emptyList) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.uiState.orderList, id: \.orderId) { order in + OrderItem(order: order) { + onNavigateOrderDetail(order.orderId) + } + } + + if viewModel.uiState.hasMore { + Button(action: { + viewModel.onEvent(.loadMore) + }) { + HStack { + if viewModel.uiState.isLoadingMore { + ProgressView() + } else { + Text(StringResources.Common.loadMore) + .font(.gmarketBody) + .foregroundColor(.accentColor) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .disabled(viewModel.uiState.isLoadingMore || viewModel.uiState.orderLoading) + } + + Spacer() + .frame(height: 100) + } + .padding(.horizontal, 16) + } + } + } + .navigationTitle(StringResources.Order.title) + .navigationBarTitleDisplayMode(.automatic) + .background(Color.background) + .onAppear { + viewModel.onEvent(.loadOrderList) + } + } + +} + + +struct OrderItemCard: View { + let order: Order + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(OrderFormatter.buildOrderTitle(order)) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .lineLimit(1) + + Text(order.agencyName ?? "-") + .font(.gmarketCaption) + .foregroundColor(Color("TextSecondary")) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 6) { + Text(order.createdAt.map { DateFormatterUtil.formatDate($0) } ?? "-") + .font(.gmarketCaption) + .foregroundColor(Color("TextSecondary")) + + StatusChip(status: order.status) + Text(formatWon(order.totalCost)) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + } + } + .padding(16) + } + .buttonStyle(PlainButtonStyle()) + .background(Color("Background_Card")) + .cornerRadius(12) + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderListViewModel.swift b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift new file mode 100644 index 0000000..596f6db --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderListViewModel.swift @@ -0,0 +1,70 @@ +// +// OrderListViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class OrderListViewModel: ObservableObject { + // MARK: - Properties + @Published var uiState = OrderListUiState() + + private let getOrderUseCase: GetOrderUseCase + private let globalMessageHandler: GlobalMessageHandler + + // MARK: - Initialization + init(getOrderUseCase: GetOrderUseCase, globalMessageHandler: GlobalMessageHandler) { + self.getOrderUseCase = getOrderUseCase + self.globalMessageHandler = globalMessageHandler + } + + // MARK: - Actions + func onEvent(_ event: OrderListUiEvent) { + switch event { + case .loadOrderList: + loadOrderList(page: 0, append: false) + case .retryOrderList: + loadOrderList(page: 0, append: false) + case .loadMore: + guard uiState.hasMore, !uiState.orderLoading, !uiState.isLoadingMore else { return } + loadOrderList(page: uiState.currentPage + 1, append: true) + } + } + + // MARK: - Private Methods + private func loadOrderList(page: Int, append: Bool) { + Task { + if append { + uiState = uiState.copy(isLoadingMore: true) + } else { + uiState = uiState.copy(orderLoading: true) + } + + do { + let (items, hasMore) = try await getOrderUseCase.execute(page: page, size: 20) + let newOrders = append ? uiState.orderList + items : items + + uiState = uiState.copy( + orderList: newOrders, + orderLoading: false, + hasMore: hasMore, + currentPage: page, + isLoadingMore: false + ) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy( + orderLoading: false, + isLoadingMore: false + ) + } + print("OrderListViewModel - loadOrderList: \(uiState)") + } + } +} diff --git a/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift b/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift new file mode 100644 index 0000000..fd3edae --- /dev/null +++ b/SampoomManagement/Features/Order/UI/OrderResultBottomSheet.swift @@ -0,0 +1,97 @@ +// +// OrderResultBottomSheet.swift +// SampoomManagement +// +// Created by 채상윤 on 10/20/25. +// + +import SwiftUI +import Combine + +struct OrderResultBottomSheet: View { + let order: Order + let onDismiss: () -> Void + @ObservedObject var viewModel: OrderDetailViewModel + + @State private var showCancelDialog = false + + var body: some View { + VStack(spacing: 0) { + Spacer() + .frame(height: 16) + + // Header + OrderCompleteHeader() + + Spacer() + .frame(height: 16) + + OrderDetailContent( + order: viewModel.uiState.orderDetail ?? order + ) + + Spacer() + .frame(height: 16) + + // Bottom Button + CommonButton( + StringResources.Order.detailOrderCancel, + isEnabled: isCancelEnabled, + backgroundColor: Color(.failRed), + textColor: .white + ) { + showCancelDialog = true + } + .padding(.horizontal, 16) + + Spacer() + .frame(height: 16) + } + .background(Color("Background")) + .alert(StringResources.Order.detailDialogOrderCancel, isPresented: $showCancelDialog) { + Button(StringResources.Common.ok) { + viewModel.setOrderId(order.orderId) + viewModel.onEvent(.cancelOrder) + } + Button(StringResources.Common.cancel, role: .cancel) { } + } + .onAppear { + viewModel.setOrderId(order.orderId) + } + .onChange(of: viewModel.uiState.isProcessingCancelSuccess) { _, newValue in + if newValue { + viewModel.clearSuccess() + viewModel.onEvent(.loadOrder) + onDismiss() + } + } + } + + private var isCancelEnabled: Bool { + if viewModel.uiState.isProcessing { return false } + let currentOrder = viewModel.uiState.orderDetail ?? order + return currentOrder.status != .completed && currentOrder.status != .canceled + } +} + +struct OrderCompleteHeader: View { + var body: some View { + HStack { + Spacer() + + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.successGreen) + .font(.title2) + + Text(StringResources.Cart.orderSuccess) + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(Color("Text")) + } + + Spacer() + } + .padding(.vertical, 16) + } +} diff --git a/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift b/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift new file mode 100644 index 0000000..5b5a68b --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Mappers/OutboundMappers.swift @@ -0,0 +1,41 @@ +// +// 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, + standardCost: standardCost + ) + } +} 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..e5819ff --- /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(agencyId: Int) async throws -> [OutboundDto] { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound", + method: .get, + responseType: [OutboundDto].self + ) + print("OutboundAPI - getOutboundList response: \(response)") + return response.data ?? [] + } + + // 출고 목록에 부품 추가 + func addOutbound(agencyId: Int, request: AddOutboundRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound", + method: .post, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 출고 처리 + func processOutbound(agencyId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound/process", + method: .post, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 출고 항목 삭제 + func deleteOutbound(agencyId: Int, outboundId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound/\(outboundId)", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 출고 수량 변경 + func updateOutbound(agencyId: Int, outboundId: Int, request: UpdateOutboundRequestDto) async throws { + guard let params = request.toDictionary() else { throw NetworkError.invalidParameters } + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound/\(outboundId)", + method: .patch, + parameters: params, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } + + // 출고 목록 전체 비우기 + func deleteAllOutbound(agencyId: Int) async throws { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/outbound/clear", + method: .delete, + responseType: EmptyResponse.self + ) + if !response.success { + throw NetworkError.serverError(response.status, message: response.message) + } + } +} + +// 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..61d8285 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Remote/DTO/OutboundDto.swift @@ -0,0 +1,29 @@ +// +// 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 + let standardCost: 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..b9c1ef6 --- /dev/null +++ b/SampoomManagement/Features/Outbound/Data/Repository/OutboundRepositoryImpl.swift @@ -0,0 +1,64 @@ +// +// OutboundRepositoryImpl.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class OutboundRepositoryImpl: OutboundRepository { + private let api: OutboundAPI + private let preferences: AuthPreferences + + init(api: OutboundAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + func getOutboundList() async throws -> OutboundList { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let data: [OutboundDto] = try await api.getOutboundList(agencyId: user.agencyId) + let outboundItems = data.map { $0.toModel() } + return OutboundList(items: outboundItems) + } + + func processOutbound() async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + try await api.processOutbound(agencyId: user.agencyId) + } + + func addOutbound(partId: Int, quantity: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let request = AddOutboundRequestDto(partId: partId, quantity: quantity) + try await api.addOutbound(agencyId: user.agencyId, request: request) + } + + func deleteOutbound(outboundId: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + try await api.deleteOutbound(agencyId: user.agencyId, outboundId: outboundId) + } + + func deleteAllOutbound() async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + try await api.deleteAllOutbound(agencyId: user.agencyId) + } + + func updateOutboundQuantity(outboundId: Int, quantity: Int) async throws { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + let request = UpdateOutboundRequestDto(quantity: quantity) + try await api.updateOutbound(agencyId: user.agencyId, outboundId: outboundId, request: request) + } +} \ No newline at end of file diff --git a/SampoomManagement/Features/Outbound/Domain/Models/Outbound+Subtotal.swift b/SampoomManagement/Features/Outbound/Domain/Models/Outbound+Subtotal.swift new file mode 100644 index 0000000..86da67c --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/Models/Outbound+Subtotal.swift @@ -0,0 +1,12 @@ +// +// Outbound+Subtotal.swift +// SampoomManagement +// + +import Foundation + +extension OutboundPart { + var subtotal: Int { standardCost * quantity } +} + + diff --git a/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift b/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift new file mode 100644 index 0000000..24ef90d --- /dev/null +++ b/SampoomManagement/Features/Outbound/Domain/Models/Outbound.swift @@ -0,0 +1,29 @@ +// +// 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 + let standardCost: 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..f05e373 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListUiState.swift @@ -0,0 +1,76 @@ +// +// 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 + ) + } + + var totalCost: Int { + outboundList.reduce(0) { acc, category in + acc + category.groups.reduce(0) { acc2, group in + acc2 + group.parts.reduce(0) { acc3, part in + acc3 + part.subtotal + } + } + } + } +} diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift new file mode 100644 index 0000000..90d4ab7 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -0,0 +1,258 @@ +// +// 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("\(formatWon(viewModel.uiState.totalCost)) \(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) + + // 소계 표시 + HStack { + Spacer() + Text(formatWon(part.subtotal)) + .font(.gmarketTitle3) + .foregroundColor(.text) + } + .padding(.horizontal, 16) + .padding(.bottom, 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..308b534 --- /dev/null +++ b/SampoomManagement/Features/Outbound/UI/OutboundListViewModel.swift @@ -0,0 +1,254 @@ +// +// 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, + standardCost: part.standardCost + ) + } 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/DI/PartDIModule.swift b/SampoomManagement/Features/Part/DI/PartDIModule.swift deleted file mode 100644 index b628e6c..0000000 --- a/SampoomManagement/Features/Part/DI/PartDIModule.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// PartDIModule.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import Foundation -import Swinject - -final class PartDIModule: Assembly { - func assemble(container: Container) { - // MARK: - Part Feature Dependencies - - // PartAPI 등록 - container.register(PartAPI.self) { resolver in - PartAPI(networkManager: resolver.resolve(NetworkManager.self)!) - }.inObjectScope(.container) - - // PartRepository 등록 (Interface -> Implementation) - container.register(PartRepository.self) { resolver in - PartRepositoryImpl(api: resolver.resolve(PartAPI.self)!) - }.inObjectScope(.container) - - // GetPartUseCase 등록 - container.register(GetPartUseCase.self) { resolver in - GetPartUseCase(repository: resolver.resolve(PartRepository.self)!) - }.inObjectScope(.container) - - // PartViewModel 등록 - container.register(PartViewModel.self) { resolver in - PartViewModel(getPartUseCase: resolver.resolve(GetPartUseCase.self)!) - }.inObjectScope(.container) - } -} diff --git a/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift b/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift index c27e5f5..1d2c056 100644 --- a/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift +++ b/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift @@ -7,12 +7,57 @@ import Foundation +extension CategoryDTO { + func toModel() -> Category { + return Category( + id: self.id, + code: self.code, + name: self.name + ) + } +} + +extension GroupDTO { + func toModel() -> PartsGroup { + return PartsGroup( + id: self.id, + code: self.code, + name: self.name, + categoryId: self.categoryId + ) + } +} + extension PartDTO { func toModel() -> Part { return Part( - id: self.id, + id: self.partId, + code: self.code, name: self.name, - count: self.count + quantity: self.quantity, + standardCost: self.standardCost ) } } + +extension SearchCategoryDTO { + func toModel() -> [SearchResult] { + return groups.flatMap { group in + group.parts.map { part in + SearchResult( + part: part.toModel(), + categoryName: categoryName, + groupName: group.groupName + ) + } + } + } +} + +extension SearchDataDTO { + func toModel() -> [SearchResult] { + return content.flatMap { category in + category.toModel() + } + } +} diff --git a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift index 9def440..dcff90f 100644 --- a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift +++ b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift @@ -6,6 +6,7 @@ // import Foundation +import Alamofire class PartAPI { private let networkManager: NetworkManager @@ -14,21 +15,49 @@ class PartAPI { self.networkManager = networkManager } - func getPartList() async throws -> PartList { - return try await withCheckedThrowingContinuation { continuation in - networkManager.request( - endpoint: "part", - 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) - } - } - } + func getCategoryList() async throws -> CategoryList { + 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 { + 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(agencyId: Int, groupId: Int) async throws -> PartList { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/group/\(groupId)", + responseType: [PartDTO].self + ) + let parts = (response.data ?? []).map { $0.toModel() } + return PartList(items: parts) + } + + func searchParts(agencyId: Int, keyword: String, page: Int = 0, size: Int = 20) async throws -> (results: [SearchResult], hasMore: Bool) { + let response = try await networkManager.request( + endpoint: "agency/\(agencyId)/search", + method: .get, + parameters: [ + "keyword": keyword, + "page": page, + "size": size + ], + responseType: SearchDataDTO.self + ) + + let data = response.data ?? SearchDataDTO(content: [], totalElements: 0, totalPages: 0, currentPage: 0) + let results = data.toModel() + let hasMore = data.currentPage < data.totalPages - 1 + + return (results: results, hasMore: hasMore) } } diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift new file mode 100644 index 0000000..27dea61 --- /dev/null +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift @@ -0,0 +1,14 @@ +// +// CategoryDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct CategoryDTO: Codable { + let id: Int + let code: String + let name: String +} diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift new file mode 100644 index 0000000..c46b010 --- /dev/null +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift @@ -0,0 +1,15 @@ +// +// GroupDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct GroupDTO: Codable { + let id: Int + let code: String + let name: String + let categoryId: Int +} diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift index 4f3c96c..636d151 100644 --- a/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift @@ -8,7 +8,9 @@ import Foundation struct PartDTO: Codable { - let id: Int + let partId: Int + let code: String let name: String - let count: Int + let quantity: Int + let standardCost: Int } diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/SearchDataDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/SearchDataDTO.swift new file mode 100644 index 0000000..9e31f8a --- /dev/null +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/SearchDataDTO.swift @@ -0,0 +1,27 @@ +// +// SearchDataDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct SearchDataDTO: Codable { + let content: [SearchCategoryDTO] + let totalElements: Int + let totalPages: Int + let currentPage: Int +} + +struct SearchCategoryDTO: Codable { + let categoryId: Int + let categoryName: String + let groups: [SearchGroupDTO] +} + +struct SearchGroupDTO: Codable { + let groupId: Int + let groupName: String + let parts: [PartDTO] +} \ No newline at end of file diff --git a/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift b/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift index 740f47f..a679ff8 100644 --- a/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift +++ b/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift @@ -9,12 +9,32 @@ import Foundation class PartRepositoryImpl: PartRepository { private let api: PartAPI + private let preferences: AuthPreferences - init(api: PartAPI) { + init(api: PartAPI, preferences: AuthPreferences) { self.api = api + self.preferences = preferences } - func getPartList() async throws -> PartList { - return try await api.getPartList() + func getCategoryList() async throws -> CategoryList { + return try await api.getCategoryList() + } + + func getGroupList(categoryId: Int) async throws -> PartsGroupList { + return try await api.getGroupList(categoryId: categoryId) + } + + func getPartList(groupId: Int) async throws -> PartList { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + return try await api.getPartList(agencyId: user.agencyId, groupId: groupId) + } + + func searchParts(keyword: String, page: Int) async throws -> (results: [SearchResult], hasMore: Bool) { + guard let user = try preferences.getStoredUser() else { + throw NetworkError.unauthorized + } + return try await api.searchParts(agencyId: user.agencyId, keyword: keyword, page: page) } } diff --git a/SampoomManagement/Features/Part/Domain/Models/Category.swift b/SampoomManagement/Features/Part/Domain/Models/Category.swift new file mode 100644 index 0000000..6273917 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/Category.swift @@ -0,0 +1,14 @@ +// +// Category.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct Category: Equatable { + let id: Int + let code: String + let name: String +} diff --git a/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift b/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift new file mode 100644 index 0000000..6a9ed4c --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift @@ -0,0 +1,22 @@ +// +// CategoryList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct CategoryList: Equatable { + let items: [Category] + var totalCount: Int { items.count } + var isEmpty: Bool { items.isEmpty } + + init(items: [Category]) { + self.items = items + } + + static func empty() -> CategoryList { + return CategoryList(items: []) + } +} diff --git a/SampoomManagement/Features/Part/Domain/Models/Part.swift b/SampoomManagement/Features/Part/Domain/Models/Part.swift index 3f3a96a..1c9b75a 100644 --- a/SampoomManagement/Features/Part/Domain/Models/Part.swift +++ b/SampoomManagement/Features/Part/Domain/Models/Part.swift @@ -7,8 +7,10 @@ import Foundation -struct Part: Identifiable, Codable, Equatable { +struct Part: Equatable { let id: Int + let code: String let name: String - let count: Int + let quantity: Int + let standardCost: Int } diff --git a/SampoomManagement/Features/Part/Domain/Models/PartList.swift b/SampoomManagement/Features/Part/Domain/Models/PartList.swift index 832c1e2..199b368 100644 --- a/SampoomManagement/Features/Part/Domain/Models/PartList.swift +++ b/SampoomManagement/Features/Part/Domain/Models/PartList.swift @@ -7,7 +7,7 @@ import Foundation -struct PartList: Codable, Equatable { +struct PartList: Equatable { let items: [Part] var totalCount: Int { items.count } var isEmpty: Bool { items.isEmpty } @@ -20,4 +20,3 @@ struct PartList: Codable, Equatable { return PartList(items: []) } } - diff --git a/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift b/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift new file mode 100644 index 0000000..c71a1c8 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift @@ -0,0 +1,15 @@ +// +// PartsGroup.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartsGroup: Equatable { + let id: Int + let code: String + let name: String + let categoryId: Int +} diff --git a/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift b/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift new file mode 100644 index 0000000..151bec5 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift @@ -0,0 +1,22 @@ +// +// PartsGroupList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartsGroupList: Equatable { + let items: [PartsGroup] + var totalCount: Int { items.count } + var isEmpty: Bool { items.isEmpty } + + init(items: [PartsGroup]) { + self.items = items + } + + static func empty() -> PartsGroupList { + return PartsGroupList(items: []) + } +} diff --git a/SampoomManagement/Features/Part/Domain/Models/SearchResult.swift b/SampoomManagement/Features/Part/Domain/Models/SearchResult.swift new file mode 100644 index 0000000..524030f --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/SearchResult.swift @@ -0,0 +1,14 @@ +// +// SearchResult.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct SearchResult: Equatable { + let part: Part + let categoryName: String + let groupName: String +} diff --git a/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift b/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift index 3a703b1..0993a54 100644 --- a/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift +++ b/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift @@ -8,5 +8,8 @@ import Foundation protocol PartRepository { - func getPartList() async throws -> PartList + func getCategoryList() async throws -> CategoryList + func getGroupList(categoryId: Int) async throws -> PartsGroupList + func getPartList(groupId: Int) async throws -> PartList + func searchParts(keyword: String, page: Int) async throws -> (results: [SearchResult], hasMore: Bool) } diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift new file mode 100644 index 0000000..52f2c36 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift @@ -0,0 +1,20 @@ +// +// GetCategoryUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class GetCategoryUseCase { + private let repository : PartRepository + + init (repository: PartRepository) { + self.repository = repository + } + + func execute() async throws -> CategoryList { + return try await repository.getCategoryList() + } +} diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift new file mode 100644 index 0000000..0e8c20e --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift @@ -0,0 +1,20 @@ +// +// GetGroupUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 9/29/25. +// + +import Foundation + +class GetGroupUseCase { + private let repository: PartRepository + + init(repository: PartRepository) { + self.repository = repository + } + + func execute(categoryId: Int) async throws -> PartsGroupList { + return try await repository.getGroupList(categoryId: categoryId) + } +} diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift index 1e9fa32..98617f9 100644 --- a/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift @@ -14,7 +14,7 @@ class GetPartUseCase { self.repository = repository } - func execute() async throws -> PartList { - return try await repository.getPartList() + func execute(groupId: Int) async throws -> PartList { + return try await repository.getPartList(groupId: groupId) } } diff --git a/SampoomManagement/Features/Part/Domain/UseCase/SearchPartsUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/SearchPartsUseCase.swift new file mode 100644 index 0000000..1e8d380 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/UseCase/SearchPartsUseCase.swift @@ -0,0 +1,20 @@ +// +// SearchPartsUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class SearchPartsUseCase { + private let repository: PartRepository + + init(repository: PartRepository) { + self.repository = repository + } + + func execute(keyword: String, page: Int) async throws -> (results: [SearchResult], hasMore: Bool) { + return try await repository.searchParts(keyword: keyword, page: page) + } +} diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift new file mode 100644 index 0000000..5a2c10b --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -0,0 +1,206 @@ +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" + @Environment(\.dismiss) private var dismiss + + 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 partPrice: String { formatWon(viewModel.uiState.part?.standardCost ?? 0) } + 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) + } + .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, + priceLabel: partPrice + ) + + 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 { + showOutboundDialog = false + // 성공 상태는 유지하고 바텀시트만 닫음 (메시지는 onDisappear에서 표시) + dismiss() + } + } + + private func handleCartSuccess(_ newValue: Bool) { + if newValue { + showCartDialog = false + // 성공 상태는 유지하고 바텀시트만 닫음 (메시지는 onDisappear에서 표시) + dismiss() + } + } +} + +private struct PartInfoHeaderView: View { + let name: String + let code: String + let quantityLabel: String + let priceLabel: String + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(name) + .font(.gmarketTitle2) + .foregroundColor(.text) + Text(code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text(priceLabel) + .font(.gmarketTitle3) + .foregroundColor(.text) + 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(StringResources.Part.quantity, 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: .accent, 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..61ace0f --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailUiEvent.swift @@ -0,0 +1,18 @@ +// +// 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 dismiss +} diff --git a/SampoomManagement/Features/Part/UI/PartDetailUiState.swift b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift new file mode 100644 index 0000000..885cabb --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailUiState.swift @@ -0,0 +1,46 @@ +// +// PartDetailUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartDetailUiState { + let part: Part? + let quantity: Int + let isUpdating: Bool + let isOutboundSuccess: Bool + let isCartSuccess: Bool + + init( + part: Part? = nil, + quantity: Int = 1, + isUpdating: Bool = false, + isOutboundSuccess: Bool = false, + isCartSuccess: Bool = false + ) { + self.part = part + self.quantity = quantity + self.isUpdating = isUpdating + self.isOutboundSuccess = isOutboundSuccess + self.isCartSuccess = isCartSuccess + } + + func copy( + part: Part?? = nil, + quantity: Int? = nil, + isUpdating: Bool? = nil, + isOutboundSuccess: Bool? = nil, + isCartSuccess: Bool? = nil + ) -> PartDetailUiState { + return PartDetailUiState( + part: part ?? self.part, + quantity: quantity ?? self.quantity, + isUpdating: isUpdating ?? self.isUpdating, + 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..394d8d4 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartDetailViewModel.swift @@ -0,0 +1,126 @@ +// +// PartDetailViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class PartDetailViewModel: ObservableObject { + @Published var uiState = PartDetailUiState() + + private let addOutboundUseCase: AddOutboundUseCase + private let addCartUseCase: AddCartUseCase + private let globalMessageHandler: GlobalMessageHandler + + init( + addOutboundUseCase: AddOutboundUseCase, + addCartUseCase: AddCartUseCase, + globalMessageHandler: GlobalMessageHandler + ) { + self.addOutboundUseCase = addOutboundUseCase + self.addCartUseCase = addCartUseCase + self.globalMessageHandler = globalMessageHandler + } + + func onEvent(_ event: PartDetailUiEvent) { + switch event { + case .initialize(let part): + uiState = uiState.copy( + part: part, + quantity: 1, + isUpdating: false, + 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 .dismiss: + uiState = uiState.copy( + part: .some(nil), + quantity: 1 + ) + } + } + + private func addToOutbound(partId: Int, quantity: Int) { + Task { + await MainActor.run { + uiState = uiState.copy(isUpdating: true) + } + + do { + try await addOutboundUseCase.execute(partId: partId, quantity: quantity) + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isOutboundSuccess: true) + } + print("PartDetailViewModel - addToOutbound success: \(uiState)") + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + print("PartDetailViewModel - addToOutbound error: \(error)") + } + } + } + + private func addToCart(partId: Int, quantity: Int) { + Task { + await MainActor.run { + uiState = uiState.copy(isUpdating: true) + } + + do { + try await addCartUseCase.execute(partId: partId, quantity: quantity) + await MainActor.run { + uiState = uiState.copy(isUpdating: false, isCartSuccess: true) + } + print("PartDetailViewModel - addToCart success: \(uiState)") + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy(isUpdating: false) + } + print("PartDetailViewModel - addToCart error: \(error)") + } + } + } + + /// 바텀시트가 닫힌 후 성공 메시지를 표시 (부품 리스트 화면에서 보임) + func showPendingSuccessMessage() { + if uiState.isOutboundSuccess { + globalMessageHandler.showMessage(StringResources.PartDetail.outboundSuccess, isError: false) + } else if uiState.isCartSuccess { + globalMessageHandler.showMessage(StringResources.PartDetail.cartSuccess, isError: false) + } + } + + func clearSuccess() { + uiState = uiState.copy(isOutboundSuccess: false, isCartSuccess: false) + } +} diff --git a/SampoomManagement/Features/Part/UI/PartItemView.swift b/SampoomManagement/Features/Part/UI/PartItemView.swift deleted file mode 100644 index 6092fd8..0000000 --- a/SampoomManagement/Features/Part/UI/PartItemView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PartItemView.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import SwiftUI - -struct PartItemView: View { - let part: Part - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(part.name) - .font(.headline) - .fontWeight(.semibold) - - Text("ID: \(part.id)") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Text("\(part.count)개") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(.blue) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - ) - } -} diff --git a/SampoomManagement/Features/Part/UI/PartListUiEvent.swift b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift new file mode 100644 index 0000000..f286e8f --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift @@ -0,0 +1,15 @@ +// +// PartListUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +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 new file mode 100644 index 0000000..43fda6c --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListUiState.swift @@ -0,0 +1,41 @@ +// +// PartListUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartListUiState { + let partList: [Part] + let partListLoading: Bool + let partListError: String? + let selectedPart: Part? + + init( + partList: [Part] = [], + partListLoading: Bool = false, + 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, + selectedPart: Part?? = nil + ) -> PartListUiState { + return PartListUiState( + partList: partList ?? self.partList, + partListLoading: partListLoading ?? self.partListLoading, + partListError: partListError ?? self.partListError, + selectedPart: selectedPart ?? self.selectedPart + ) + } +} diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift new file mode 100644 index 0000000..93e5acc --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -0,0 +1,135 @@ +// +// PartListView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import SwiftUI + +struct PartListView: View { + @ObservedObject var viewModel: PartListViewModel + @State private var showBottomSheet = false + let dependencies: AppDependencies + + init( + viewModel: PartListViewModel, + dependencies: AppDependencies + ) { + self.viewModel = viewModel + self.dependencies = dependencies + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.uiState.partListLoading { + // 로딩 상태 + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let error = viewModel.uiState.partListError { + // 에러 상태 + Spacer() + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryPartList) } + ) + .frame(height: 200) + Spacer() + } else if viewModel.uiState.partList.isEmpty { + // 빈 상태 + Spacer() + EmptyView( + icon: "tray", + title: StringResources.Part.emptyPart + ) + .frame(height: 200) + Spacer() + } else { + // 부품 리스트 + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.uiState.partList, id: \.id) { part in + PartListItemCard( + part: part, + onClick: { + viewModel.onEvent(.showBottomSheet(part)) + showBottomSheet = true + } + ) + } + } + .padding(16) + } + } + } + .navigationTitle(StringResources.Tabs.parts) + .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 { + // 바텀시트가 닫힌 후 성공 메시지를 부품 리스트 화면에서 표시 + detailViewModel.showPendingSuccessMessage() + detailViewModel.clearSuccess() + 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 { + Button(action: onClick) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(formatWon(part.standardCost)) + .font(.gmarketBody) + .foregroundColor(.text) + Text("\(part.quantity)") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + } + .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 new file mode 100644 index 0000000..532d2c3 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListViewModel.swift @@ -0,0 +1,75 @@ +// +// PartListViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class PartListViewModel: ObservableObject { + @Published var uiState = PartListUiState() + + private let getPartUseCase: GetPartUseCase + private let groupId: Int + private var loadTask: Task? + + init( + getPartUseCase: GetPartUseCase, + groupId: Int + ) { + self.getPartUseCase = getPartUseCase + self.groupId = groupId + + loadPartList() + } + + func onEvent(_ event: PartListUiEvent) { + switch event { + case .loadPartList: + loadPartList() + case .retryPartList: + loadPartList() + case .showBottomSheet(let part): + uiState = uiState.copy(selectedPart: part) + case .dismissBottomSheet: + uiState = uiState.copy(selectedPart: .some(nil)) + } + } + + private func loadPartList() { + // 이전 작업 취소 + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { return } + // 로딩 상태 진입은 메인에서 + await MainActor.run { + self.uiState = self.uiState.copy(partListLoading: true, partListError: .some(nil)) + } + do { + let partList = try await self.getPartUseCase.execute(groupId: self.groupId) + try Task.checkCancellation() + await MainActor.run { + self.uiState = self.uiState.copy( + partList: partList.items, + partListLoading: false, + partListError: .some(nil) + ) + } + } catch is CancellationError { + // 취소는 무시 + } catch { + await MainActor.run { + self.uiState = self.uiState.copy( + partListLoading: false, + partListError: error.localizedDescription + ) + } + } + print("PartListViewModel - loadPartList: \(self.uiState)") + } + } +} diff --git a/SampoomManagement/Features/Part/UI/PartUIState.swift b/SampoomManagement/Features/Part/UI/PartUIState.swift index 8f5e289..01b0256 100644 --- a/SampoomManagement/Features/Part/UI/PartUIState.swift +++ b/SampoomManagement/Features/Part/UI/PartUIState.swift @@ -7,35 +7,57 @@ import Foundation -struct PartUIState: UIState { - let loading: Bool - let error: String? - let success: Bool - let partList: [Part] +struct PartUIState { + // Part + let groupList: [PartsGroup] + let groupLoading: Bool + let groupError: String? + + let selectedCategory: Category? + + // Category + let categoryList: [Category] + let categoryLoading: Bool + let categoryError: String? init( - loading: Bool = false, - error: String? = nil, - success: Bool = false, - partList: [Part] = [] + groupList: [PartsGroup] = [], + groupLoading: Bool = false, + groupError: String? = nil, + selectedCategory: Category? = nil, + categoryList: [Category] = [], + categoryLoading: Bool = false, + categoryError: String? = nil ) { - self.loading = loading - self.error = error - self.success = success - self.partList = partList + self.groupList = groupList + self.groupLoading = groupLoading + self.groupError = groupError + + self.selectedCategory = selectedCategory + + self.categoryList = categoryList + self.categoryLoading = categoryLoading + self.categoryError = categoryError } func copy( - loading: Bool? = nil, - error: String? = nil, - success: Bool? = nil, - partList: [Part]? = nil + groupList: [PartsGroup]? = nil, + groupLoading: Bool? = nil, + groupError: String?? = nil, + selectedCategory: Category?? = nil, + categoryList: [Category]? = nil, + categoryLoading: Bool? = nil, + categoryError: String?? = nil ) -> PartUIState { return PartUIState( - loading: loading ?? self.loading, - error: error ?? self.error, - success: success ?? self.success, - partList: partList ?? self.partList + groupList: groupList ?? self.groupList, + groupLoading: groupLoading ?? self.groupLoading, + groupError: groupError ?? self.groupError, + selectedCategory: selectedCategory ?? self.selectedCategory, + categoryList: categoryList ?? self.categoryList, + categoryLoading: categoryLoading ?? self.categoryLoading, + categoryError: categoryError ?? self.categoryError ) } } + diff --git a/SampoomManagement/Features/Part/UI/PartUiEvent.swift b/SampoomManagement/Features/Part/UI/PartUiEvent.swift new file mode 100644 index 0000000..19df5b0 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartUiEvent.swift @@ -0,0 +1,15 @@ +// +// PartUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum PartUiEvent { + case loadCategories + case categorySelected(Category) + case retryCategories + case retryGroups +} diff --git a/SampoomManagement/Features/Part/UI/PartView.swift b/SampoomManagement/Features/Part/UI/PartView.swift index aee7f01..4a574f2 100644 --- a/SampoomManagement/Features/Part/UI/PartView.swift +++ b/SampoomManagement/Features/Part/UI/PartView.swift @@ -8,58 +8,243 @@ import SwiftUI struct PartView: View { - @EnvironmentObject var viewModel: PartViewModel - @State var searchString = "" + @ObservedObject var viewModel: PartViewModel + @ObservedObject var searchViewModel: SearchViewModel + @State private var searchQuery = "" + + let onNavigatePartList: (PartsGroup) -> Void + + init( + onNavigatePartList: @escaping (PartsGroup) -> Void, + viewModel: PartViewModel, + searchViewModel: SearchViewModel + ) { + self.onNavigatePartList = onNavigatePartList + self.viewModel = viewModel + self.searchViewModel = searchViewModel + } var body: some View { - NavigationStack { - VStack(spacing: 0) { - contentView + ZStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Category 선택 제목 + Text(StringResources.Part.selectCategory) + .font(.gmarketTitle2) + .fontWeight(.bold) + .padding(.horizontal, 16) + + // Category 섹션 + categorySection + + Spacer() + .frame(height: 24) + + // 그룹 리스트 섹션 + groupSection + } + .padding(.vertical, 16) + } + + // 검색 결과 오버레이 + if !searchQuery.isEmpty { + SearchResultView( + viewModel: searchViewModel, + partDetailViewModel: searchViewModel.partDetailViewModel + ) + .background(Color.background) + } + } + .navigationTitle(StringResources.Tabs.parts) + .navigationBarTitleDisplayMode(.automatic) + .searchable(text: $searchQuery, prompt: StringResources.SearchParts.placeholder) + .onChange(of: searchQuery) { _, newValue in + if newValue.isEmpty { + searchViewModel.onEvent(.clearSearch) + } else { + searchViewModel.onEvent(.search(newValue)) } - .navigationBarTitle(Text("부품")) - .searchable(text: $searchString) } + .background(Color.background) } @ViewBuilder - private var contentView: some View { - if viewModel.uiState.loading { - loadingView - } else if let error = viewModel.uiState.error { - errorView(error: error) - } else if viewModel.uiState.partList.isEmpty { - emptyView + private var categorySection: some View { + if viewModel.uiState.categoryLoading { + // 로딩 상태 + HStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 200) + } else if let error = viewModel.uiState.categoryError { + // 에러 상태 + HStack { + Spacer() + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryCategories) } + ) + Spacer() + } + .frame(height: 200) + } else if viewModel.uiState.categoryList.isEmpty { + // 빈 상태 + HStack { + Spacer() + EmptyView( + icon: "tray", + title: "카테고리가 없습니다" + ) + Spacer() + } + .frame(height: 200) } else { - listView + // 카테고리 그리드 + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { + ForEach(viewModel.uiState.categoryList, id: \.id) { category in + CategoryItem( + category: category, + isSelected: category.id == viewModel.uiState.selectedCategory?.id, + onClick: { + viewModel.onEvent(.categorySelected(category)) + } + ) + } + } + .padding(.horizontal, 16) } } - private var loadingView: some View { - LoadingView() + @ViewBuilder + private var groupSection: some View { + if viewModel.uiState.selectedCategory == nil { + // 초기 상태: 카테고리 선택 안내 + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(.gray) + + Text(StringResources.Part.selectCategoryPrompt) + .font(.gmarketBody) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else { + // 그룹 선택 제목 + Text(StringResources.Part.selectGroup) + .font(.gmarketTitle2) + .fontWeight(.bold) + .padding(.horizontal, 16) + + // 그룹 리스트 + if viewModel.uiState.groupLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 200) + } else if let error = viewModel.uiState.groupError { + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryGroups) } + ) + .frame(height: 200) + } else if viewModel.uiState.groupList.isEmpty { + HStack { + Spacer() + EmptyView( + icon: "tray", + title: "그룹이 없습니다" + ) + Spacer() + } + .frame(height: 200) + } else { + LazyVStack(spacing: 8) { + ForEach(viewModel.uiState.groupList, id: \.id) { group in + PartItemCard( + group: group, + onClick: { onNavigatePartList(group) } + ) + } + } + .padding(.horizontal, 16) + } + } } +} + + +// Category 아이템 +struct CategoryItem: View { + let category: Category + let isSelected: Bool + let onClick: () -> Void - private func errorView(error: String) -> some View { - ErrorView(error: error) { - viewModel.refreshPart() + var body: some View { + Button(action: onClick) { + VStack(spacing: 4) { + Image(categoryIcon(for: category.code)) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? .white : .text) + + Text(category.name) + .font(.gmarketCaption) + .foregroundColor(isSelected ? .white : .text) + } + .frame(height: 100) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected ? .accent : .backgroundCard) + ) } + .buttonStyle(PlainButtonStyle()) } - private var emptyView: some View { - EmptyStateView( - icon: "tray", - title: "인벤토리가 비어있습니다" - ) + private func categoryIcon(for code: String) -> String { + switch code { + case "ENG": return "engine" + case "TRN": return "transmission" + case "CHS": return "chassis" + case "BDY": return "body" + case "TRM": return "trim" + case "ELE": return "electric" + default: return "parts" + } } +} + +// Part 아이템 카드 +struct PartItemCard: View { + let group: PartsGroup + let onClick: () -> Void - private var listView: some View { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(viewModel.uiState.partList) { part in - PartItemView(part: part) - } + var body: some View { + Button(action: onClick) { + HStack { + Text(group.name) + .font(.gmarketBody) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.backgroundCard)) + ) } + .buttonStyle(PlainButtonStyle()) } } diff --git a/SampoomManagement/Features/Part/UI/PartViewModel.swift b/SampoomManagement/Features/Part/UI/PartViewModel.swift index 1297624..2072ab4 100644 --- a/SampoomManagement/Features/Part/UI/PartViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartViewModel.swift @@ -12,36 +12,84 @@ import Combine @MainActor class PartViewModel: ObservableObject { @Published var uiState = PartUIState() + + private let getCategoryUseCase: GetCategoryUseCase + private let getGroupUseCase: GetGroupUseCase - private let getPartUseCase: GetPartUseCase + init( + getCategoryUseCase: GetCategoryUseCase, + getGroupUseCase: GetGroupUseCase + ) { + self.getCategoryUseCase = getCategoryUseCase + self.getGroupUseCase = getGroupUseCase + loadCategory() + } - init(getPartUseCase: GetPartUseCase) { - self.getPartUseCase = getPartUseCase - loadPart() + func onEvent(_ event: PartUiEvent) { + switch event { + case .loadCategories: + loadCategory() + case .categorySelected(let category): + selectCategory(category) + case .retryCategories: + loadCategory() + case .retryGroups: + loadGroup() + } } - private func loadPart() { + private func loadCategory() { Task { - uiState = uiState.copy(loading: true, error: nil) + uiState = uiState.copy(categoryLoading: true, categoryError: .some(nil)) do { - let partList = try await getPartUseCase.execute() + let categoryList = try await getCategoryUseCase.execute() uiState = uiState.copy( - loading: false, - success: true, - partList: partList.items + categoryList: categoryList.items, + categoryLoading: false, + categoryError: .some(nil) ) } catch { uiState = uiState.copy( - loading: false, - error: error.localizedDescription + categoryLoading: false, + categoryError: error.localizedDescription ) } + print("PartViewModel - loadCategory: \(uiState)") + } + } + + private func selectCategory(_ category: Category) { + Task { + uiState = uiState.copy(selectedCategory: category) + await loadGroup(categoryId: category.id) + } + } + + private func loadGroup(categoryId: Int) async { + uiState = uiState.copy(groupLoading: true, groupError: .some(nil)) + + do { + let groupList = try await getGroupUseCase.execute(categoryId: categoryId) + uiState = uiState.copy( + groupList: groupList.items, + groupLoading: false, + groupError: .some(nil) + ) + } catch { + uiState = uiState.copy( + groupLoading: false, + groupError: error.localizedDescription + ) } + print("PartViewModel - loadGroup: \(uiState)") } - func refreshPart() { - loadPart() + private func loadGroup() { + guard let selectedCategory = uiState.selectedCategory else { return } + Task { + await loadGroup(categoryId: selectedCategory.id) + } } } diff --git a/SampoomManagement/Features/Part/UI/SearchResultView.swift b/SampoomManagement/Features/Part/UI/SearchResultView.swift new file mode 100644 index 0000000..17d3305 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/SearchResultView.swift @@ -0,0 +1,168 @@ +// +// SearchResultView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import SwiftUI + +struct SearchResultView: View { + @ObservedObject var viewModel: SearchViewModel + @ObservedObject var partDetailViewModel: PartDetailViewModel + @State private var showBottomSheet = false + + var body: some View { + VStack(spacing: 0) { + if viewModel.uiState.isSearching { + loadingView + } else if let error = viewModel.uiState.searchError { + errorView(error: error) + } else if viewModel.uiState.searchResults.isEmpty { + emptyView + } else { + searchResultsList + } + } + .sheet(isPresented: $showBottomSheet) { + if let selectedPart = viewModel.uiState.selectedPart { + PartDetailBottomSheetView(viewModel: partDetailViewModel) + .onAppear { + partDetailViewModel.onEvent(.initialize(selectedPart)) + } + .onDisappear { + // 바텀시트가 닫힌 후 성공 메시지를 검색 결과 화면에서 표시 + partDetailViewModel.showPendingSuccessMessage() + partDetailViewModel.clearSuccess() + showBottomSheet = false + viewModel.onEvent(.dismissBottomSheet) + } + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + .presentationBackground(.clear) + } + } + .onChange(of: viewModel.uiState.selectedPart) { _, newValue in + showBottomSheet = newValue != nil + } + } + + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + .scaleEffect(1.5) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(error: String) -> some View { + HStack { + Spacer() + ErrorView( + error: error, + onRetry: { + // 검색 재시도는 상위에서 처리 + } + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + HStack { + Spacer() + EmptyView(title: StringResources.SearchParts.emptyMessage) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var searchResultsList: some View { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(Array(viewModel.uiState.searchResults.enumerated()), id: \.element.part.id) { index, searchResult in + SearchResultItemCard( + searchResult: searchResult, + onClick: { + viewModel.onEvent(.showBottomSheet(searchResult.part)) + } + ) + .onAppear { + // 마지막에서 3번째 아이템이 보이면 다음 페이지 로드 + if index >= viewModel.uiState.searchResults.count - 3 { + loadMoreIfNeeded() + } + } + } + + // 무한 스크롤 로딩 인디케이터 + if viewModel.uiState.isLoadingMore { + HStack { + Spacer() + ProgressView() + .scaleEffect(0.8) + Spacer() + } + .padding(.vertical, 8) + } + } + .padding(.horizontal, 16) + } + } + + private func loadMoreIfNeeded() { + // 스크롤이 마지막 아이템 근처에 도달했을 때 다음 페이지 로드 + if viewModel.uiState.hasMorePages && !viewModel.uiState.isLoadingMore { + viewModel.onEvent(.loadMore) + } + } +} + +struct SearchResultItemCard: View { + let searchResult: SearchResult + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(searchResult.categoryName) > \(searchResult.groupName)") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + + Text(searchResult.part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(searchResult.part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + Text(formatWon(searchResult.part.standardCost)) + .font(.gmarketBody) + .foregroundColor(.text) + Text("x \(searchResult.part.quantity)") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.textSecondary) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.backgroundCard) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/SampoomManagement/Features/Part/UI/SearchUiEvent.swift b/SampoomManagement/Features/Part/UI/SearchUiEvent.swift new file mode 100644 index 0000000..e54fc84 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/SearchUiEvent.swift @@ -0,0 +1,16 @@ +// +// SearchUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum SearchUiEvent { + case search(String) + case loadMore + case clearSearch + case showBottomSheet(Part) + case dismissBottomSheet +} diff --git a/SampoomManagement/Features/Part/UI/SearchUiState.swift b/SampoomManagement/Features/Part/UI/SearchUiState.swift new file mode 100644 index 0000000..51efd37 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/SearchUiState.swift @@ -0,0 +1,56 @@ +// +// SearchUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct SearchUiState { + let searchResults: [SearchResult] + let isSearching: Bool + let searchError: String? + let selectedPart: Part? + let currentPage: Int + let hasMorePages: Bool + let isLoadingMore: Bool + + init( + searchResults: [SearchResult] = [], + isSearching: Bool = false, + searchError: String? = nil, + selectedPart: Part? = nil, + currentPage: Int = 0, + hasMorePages: Bool = true, + isLoadingMore: Bool = false + ) { + self.searchResults = searchResults + self.isSearching = isSearching + self.searchError = searchError + self.selectedPart = selectedPart + self.currentPage = currentPage + self.hasMorePages = hasMorePages + self.isLoadingMore = isLoadingMore + } + + func copy( + searchResults: [SearchResult]? = nil, + isSearching: Bool? = nil, + searchError: String?? = nil, + selectedPart: Part?? = nil, + currentPage: Int? = nil, + hasMorePages: Bool? = nil, + isLoadingMore: Bool? = nil + ) -> SearchUiState { + return SearchUiState( + searchResults: searchResults ?? self.searchResults, + isSearching: isSearching ?? self.isSearching, + searchError: searchError.flatMap { $0 } ?? self.searchError, + selectedPart: selectedPart.flatMap { $0 } ?? self.selectedPart, + currentPage: currentPage ?? self.currentPage, + hasMorePages: hasMorePages ?? self.hasMorePages, + isLoadingMore: isLoadingMore ?? self.isLoadingMore + ) + } +} diff --git a/SampoomManagement/Features/Part/UI/SearchViewModel.swift b/SampoomManagement/Features/Part/UI/SearchViewModel.swift new file mode 100644 index 0000000..716bfba --- /dev/null +++ b/SampoomManagement/Features/Part/UI/SearchViewModel.swift @@ -0,0 +1,149 @@ +// +// SearchViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class SearchViewModel: ObservableObject { + @Published var uiState = SearchUiState() + + private let searchPartsUseCase: SearchPartsUseCase + let partDetailViewModel: PartDetailViewModel + private var searchTask: Task? + private var currentKeyword: String = "" + + init(searchPartsUseCase: SearchPartsUseCase, partDetailViewModel: PartDetailViewModel) { + self.searchPartsUseCase = searchPartsUseCase + self.partDetailViewModel = partDetailViewModel + } + + func onEvent(_ event: SearchUiEvent) { + switch event { + case .search(let keyword): + searchParts(keyword: keyword) + case .loadMore: + loadMoreResults() + case .clearSearch: + clearSearch() + case .showBottomSheet(let part): + showBottomSheet(part: part) + case .dismissBottomSheet: + dismissBottomSheet() + } + } + + private func searchParts(keyword: String) { + // 이전 검색 작업 취소 + searchTask?.cancel() + + guard !keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + uiState = uiState.copy( + searchResults: [], + isSearching: false, + searchError: nil, + currentPage: 0, + hasMorePages: true + ) + return + } + + currentKeyword = keyword + uiState = uiState.copy( + isSearching: true, + searchError: .some(nil), + currentPage: 0, + hasMorePages: true + ) + + searchTask = Task { + // 0.3초 debounce + try? await Task.sleep(nanoseconds: 300_000_000) + + guard !Task.isCancelled else { return } + + do { + let (results, hasMore) = try await searchPartsUseCase.execute(keyword: keyword, page: 0) + + await MainActor.run { + uiState = uiState.copy( + searchResults: results, + isSearching: false, + searchError: nil, + currentPage: 0, + hasMorePages: hasMore + ) + } + } catch { + await MainActor.run { + uiState = uiState.copy( + searchResults: [], + isSearching: false, + searchError: error.localizedDescription, + currentPage: 0, + hasMorePages: false + ) + } + } + } + } + + private func loadMoreResults() { + guard !currentKeyword.isEmpty, + uiState.hasMorePages, + !uiState.isLoadingMore else { return } + + uiState = uiState.copy(isLoadingMore: true) + + Task { + do { + let nextPage = uiState.currentPage + 1 + let (newResults, hasMore) = try await searchPartsUseCase.execute(keyword: currentKeyword, page: nextPage) + + await MainActor.run { + let combinedResults = uiState.searchResults + newResults + uiState = uiState.copy( + searchResults: combinedResults, + currentPage: nextPage, + hasMorePages: hasMore, + isLoadingMore: false + ) + } + } catch { + await MainActor.run { + uiState = uiState.copy( + searchError: error.localizedDescription, + isLoadingMore: false + ) + } + } + } + } + + private func clearSearch() { + searchTask?.cancel() + currentKeyword = "" + uiState = uiState.copy( + searchResults: [], + isSearching: false, + searchError: .some(nil), + currentPage: 0, + hasMorePages: true + ) + } + + private func showBottomSheet(part: Part) { + uiState = uiState.copy(selectedPart: part) + partDetailViewModel.onEvent(.initialize(part)) + } + + private func dismissBottomSheet() { + uiState = uiState.copy(selectedPart: nil) + partDetailViewModel.onEvent(.dismiss) + } +} diff --git a/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift b/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift new file mode 100644 index 0000000..9ed9b3f --- /dev/null +++ b/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift @@ -0,0 +1,15 @@ +// +// SettingUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum SettingUiEvent { + case loadProfile + case editProfile + case logout +} + diff --git a/SampoomManagement/Features/Setting/UI/SettingUiState.swift b/SampoomManagement/Features/Setting/UI/SettingUiState.swift new file mode 100644 index 0000000..8be2ab1 --- /dev/null +++ b/SampoomManagement/Features/Setting/UI/SettingUiState.swift @@ -0,0 +1,36 @@ +// +// SettingUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct SettingUiState { + let loading: Bool + let error: String? + + static let initial = SettingUiState( + loading: false, + error: nil + ) + + func copy( + loading: Bool? = nil, + error: String?? = nil + ) -> SettingUiState { + let resolvedError: String? + if let error = error { + resolvedError = error + } else { + resolvedError = self.error + } + + return SettingUiState( + loading: loading ?? self.loading, + error: resolvedError + ) + } +} + diff --git a/SampoomManagement/Features/Setting/UI/SettingView.swift b/SampoomManagement/Features/Setting/UI/SettingView.swift new file mode 100644 index 0000000..1e51b1a --- /dev/null +++ b/SampoomManagement/Features/Setting/UI/SettingView.swift @@ -0,0 +1,126 @@ +// +// SettingView.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct SettingView: View { + @ObservedObject var viewModel: SettingViewModel + let onNavigateBack: () -> Void + let onLogoutClick: () -> Void + @State private var showLogoutDialog = false + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 16) { + if let user = viewModel.user { + userSection(user: user) + } + + settingSection() + + Spacer(minLength: 100) + } + .padding(.horizontal, 16) + } + } + .navigationTitle(StringResources.Setting.title) + .navigationBarTitleDisplayMode(.large) + .background(Color.background) + .refreshable { + viewModel.onEvent(.loadProfile) + } + .onAppear { + viewModel.onEvent(.loadProfile) + } + .alert("로그아웃", isPresented: $showLogoutDialog) { + Button(StringResources.Common.cancel, role: .cancel) {} + Button(StringResources.Common.confirm) { + Task { + if await viewModel.logout() { + onLogoutClick() + } + } + } + } message: { + Text(StringResources.Setting.dialogLogout) + } + } + + private func userSection(user: User) -> some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text(user.name) + .font(.gmarketTitle) + .foregroundColor(.text) + + Text(user.position.displayNameKo) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 8) { + Text(user.email) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + + if let startedAt = user.startedAt, !startedAt.isEmpty { + Text(DateFormatterUtil.formatDate(startedAt)) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + if let endedAt = user.endedAt, !endedAt.isEmpty { + Text(DateFormatterUtil.formatDate(endedAt)) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + } + } + .padding(.vertical, 16) + } + + private func settingSection() -> some View { + VStack(spacing: 8) { + Button(action: { + // TODO: Edit profile + viewModel.onEvent(.editProfile) + }) { + HStack { + Text(StringResources.Setting.editProfile) + .font(.gmarketBody) + .foregroundColor(.text) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + } + .padding(16) + .background(Color.backgroundCard) + .cornerRadius(12) + } + + Button(action: { + showLogoutDialog = true + }) { + HStack { + Text(StringResources.Setting.logout) + .font(.gmarketBody) + .foregroundColor(.text) + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + } + .padding(16) + .background(Color.backgroundCard) + .cornerRadius(12) + } + } + } +} + diff --git a/SampoomManagement/Features/Setting/UI/SettingViewModel.swift b/SampoomManagement/Features/Setting/UI/SettingViewModel.swift new file mode 100644 index 0000000..431faae --- /dev/null +++ b/SampoomManagement/Features/Setting/UI/SettingViewModel.swift @@ -0,0 +1,63 @@ +// +// SettingViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class SettingViewModel: ObservableObject { + @Published var uiState = SettingUiState.initial + @Published var user: User? + + private let authPreferences: AuthPreferences + private let signOutUseCase: SignOutUseCase + private let globalMessageHandler: GlobalMessageHandler + + init( + authPreferences: AuthPreferences, + signOutUseCase: SignOutUseCase, + globalMessageHandler: GlobalMessageHandler + ) { + self.authPreferences = authPreferences + self.signOutUseCase = signOutUseCase + self.globalMessageHandler = globalMessageHandler + } + + func onEvent(_ event: SettingUiEvent) { + switch event { + case .loadProfile: + loadProfile() + case .editProfile: + // TODO: Implement edit profile + break + case .logout: + // Deprecated in favor of explicit async logout() from the View + break + } + } + + private func loadProfile() { + do { + user = try authPreferences.getStoredUser() + } catch { + uiState = uiState.copy(error: error.localizedDescription) + } + } + + func logout() async -> Bool { + do { + try await signOutUseCase.execute() + return true + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + globalMessageHandler.showMessage(errorMessage, isError: true) + return false + } + } +} + diff --git a/SampoomManagement/Resources/Info.plist b/SampoomManagement/Info.plist similarity index 53% rename from SampoomManagement/Resources/Info.plist rename to SampoomManagement/Info.plist index 781d890..974a51b 100644 --- a/SampoomManagement/Resources/Info.plist +++ b/SampoomManagement/Info.plist @@ -2,6 +2,11 @@ - + UIAppFonts + + GmarketSansBold.otf + GmarketSansLight.otf + GmarketSansMedium.otf + diff --git a/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json new file mode 100644 index 0000000..f267be4 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/Disable.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEC", + "green" : "0xEA", + "red" : "0xE9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x44", + "green" : "0x44", + "red" : "0x44" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/FailRed.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/FailRed.colorset/Contents.json new file mode 100644 index 0000000..4509f93 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/FailRed.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x6C", + "green" : "0x6C", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/SuccessGreen.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/SuccessGreen.colorset/Contents.json new file mode 100644 index 0000000..ec03d62 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/SuccessGreen.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x81", + "green" : "0xB9", + "red" : "0x10" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json new file mode 100644 index 0000000..6e89205 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/TextSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x7C", + "green" : "0x7C", + "red" : "0x7C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCC", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/WaitYellow.colorset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/WaitYellow.colorset/Contents.json new file mode 100644 index 0000000..190695a --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/WaitYellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0B", + "green" : "0x9E", + "red" : "0xF5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/block.imageset/Contents.json similarity index 77% rename from SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json rename to SampoomManagement/Resources/Assets.xcassets/block.imageset/Contents.json index b5b9b10..9fbef6a 100644 --- a/SampoomManagement/Resources/Assets.xcassets/delivery.imageset/Contents.json +++ b/SampoomManagement/Resources/Assets.xcassets/block.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "delivery.svg", + "filename" : "block.svg", "idiom" : "universal" } ], diff --git a/SampoomManagement/Resources/Assets.xcassets/block.imageset/block.svg b/SampoomManagement/Resources/Assets.xcassets/block.imageset/block.svg new file mode 100644 index 0000000..6ff1c81 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/block.imageset/block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json new file mode 100644 index 0000000..01be4d5 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "body.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg b/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg new file mode 100644 index 0000000..17e3dc5 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/car.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/car.imageset/Contents.json new file mode 100644 index 0000000..d059f1a --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/car.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "car.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/car.imageset/car.svg b/SampoomManagement/Resources/Assets.xcassets/car.imageset/car.svg new file mode 100644 index 0000000..9ac9add --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/car.imageset/car.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json new file mode 100644 index 0000000..71d0d2f --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "chassis.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg new file mode 100644 index 0000000..8311790 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json new file mode 100644 index 0000000..984f80c --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "electric.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg new file mode 100644 index 0000000..cceeca9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json new file mode 100644 index 0000000..2ae59ca --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "engine.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg new file mode 100644 index 0000000..27a13bf --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json new file mode 100644 index 0000000..46b5fc4 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "money.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg b/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg new file mode 100644 index 0000000..efc6018 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json new file mode 100644 index 0000000..993f3bb --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "oneline_logo.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "oneline_logo 1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "oneline_logo_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg new file mode 100644 index 0000000..81fed1b --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg new file mode 100644 index 0000000..81fed1b --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg new file mode 100644 index 0000000..0c394f7 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/oneline_logo.imageset/oneline_logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json new file mode 100644 index 0000000..81dfa04 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/outbound.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "outbound.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json new file mode 100644 index 0000000..eb77ad9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/Contents.json @@ -0,0 +1,32 @@ +{ + "images" : [ + { + "filename" : "square_logo_light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "square_logo_light 1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "square_logo_dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg new file mode 100644 index 0000000..755b00d --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg new file mode 100644 index 0000000..c4e76e9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light 1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg new file mode 100644 index 0000000..c4e76e9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/square_logo.imageset/square_logo_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json new file mode 100644 index 0000000..5919728 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "transmission.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg new file mode 100644 index 0000000..70d8691 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg @@ -0,0 +1,3 @@ + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json new file mode 100644 index 0000000..f51b710 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "trim.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg new file mode 100644 index 0000000..28e579a --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json new file mode 100644 index 0000000..b9aa7c2 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "warning.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg new file mode 100644 index 0000000..c1c0ad7 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/SampoomManagement/Resources/Fonts/GmarketSansBold.otf b/SampoomManagement/Resources/Fonts/GmarketSansBold.otf new file mode 100644 index 0000000..3a7ab60 Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansBold.otf differ diff --git a/SampoomManagement/Resources/Fonts/GmarketSansLight.otf b/SampoomManagement/Resources/Fonts/GmarketSansLight.otf new file mode 100644 index 0000000..c588d3e Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansLight.otf differ diff --git a/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf b/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf new file mode 100644 index 0000000..af2cfc3 Binary files /dev/null and b/SampoomManagement/Resources/Fonts/GmarketSansMedium.otf differ