diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift index e05c8c4..37a69ae 100644 --- a/SampoomManagement/App/RootView.swift +++ b/SampoomManagement/App/RootView.swift @@ -77,11 +77,22 @@ struct RootView: View { // Toast 컨테이너 (앱 최상단에 배치) ToastContainer(globalMessageHandler: globalMessageHandler) } + .onChange(of: authViewModel.isLoggedIn) { _, isLoggedIn in + if !isLoggedIn { + // 로그아웃 시 LoginViewModel 상태 리셋 + loginViewModel.uiState = LoginUiState() + showSignUp = false + } + } .onChange(of: authViewModel.shouldNavigateToLogin) { _, shouldNavigate in if shouldNavigate { showSignUp = false authViewModel.resetNavigationState() } } + .onReceive(NotificationCenter.default.publisher(for: .didRequestLogout)) { _ in + showSignUp = false + Task { await authViewModel.handleTokenExpired() } + } } } diff --git a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift index 1476706..0839f2f 100644 --- a/SampoomManagement/Core/Network/AuthRequestInterceptor.swift +++ b/SampoomManagement/Core/Network/AuthRequestInterceptor.swift @@ -8,6 +8,10 @@ import Foundation import Alamofire +extension Notification.Name { + static let didRequestLogout = Notification.Name("didRequestLogout") +} + // 비동기-세이프 토큰 갱신 조정자 actor RefreshCoordinator { private var inFlight: Task? @@ -39,6 +43,13 @@ final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable { func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { var adaptedRequest = urlRequest + if let urlString = adaptedRequest.url?.absoluteString, + urlString.contains("/auth/refresh") { + adaptedRequest.setValue(nil, forHTTPHeaderField: "Authorization") + completion(.success(adaptedRequest)) + return + } + do { if let accessToken = try authPreferences.getAccessToken() { adaptedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") @@ -54,12 +65,6 @@ final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable { // 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) @@ -69,11 +74,24 @@ final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable { Task { do { _ = try await refreshCoordinator.refresh(using: tokenRefreshService) + + if request.retryCount >= 1 { + await authPreferences.clear() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .didRequestLogout, object: nil) + } + completion(.doNotRetry) + return + } + completion(.retryWithDelay(0.1)) } catch { print("AuthRequestInterceptor - 토큰 재발급 실패: \(error)") // 토큰 재발급 실패 시 로그아웃 처리 await authPreferences.clear() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .didRequestLogout, object: nil) + } completion(.doNotRetry) } } diff --git a/SampoomManagement/Core/Network/NetworkManager.swift b/SampoomManagement/Core/Network/NetworkManager.swift index bfe7790..148654e 100644 --- a/SampoomManagement/Core/Network/NetworkManager.swift +++ b/SampoomManagement/Core/Network/NetworkManager.swift @@ -55,33 +55,9 @@ class NetworkManager { parameters: parameters, encoding: method == .get ? URLEncoding.default : JSONEncoding.default ) + .validate(statusCode: 200..<300) 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 @@ -97,7 +73,37 @@ class NetworkManager { } case .failure(let error): print("NetworkManager - Network error: \(error)") - continuation.resume(throwing: NetworkError.networkError(error)) + + if let data = response.data { + Task { @MainActor in + // 1. ApiErrorResponse 형식으로 파싱 시도 + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let statusCode = errorResponse.code ?? response.response?.statusCode ?? -1 + continuation.resume(throwing: NetworkError.serverError(statusCode, message: errorResponse.message)) + return + } + + // 2. APIResponse 형식으로 파싱 시도 + if let httpResponse = response.response, + let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + // 3. HTTP 상태 코드 존재 시 기본 에러 + if let httpResponse = response.response { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + return + } + + // 4. 기타 네트워크 에러 + continuation.resume(throwing: NetworkError.networkError(error)) + } + } else if let httpResponse = response.response { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } else { + continuation.resume(throwing: NetworkError.networkError(error)) + } } } } @@ -123,40 +129,17 @@ class NetworkManager { parameters: body, encoder: JSONParameterEncoder.default ) + .validate(statusCode: 200..<300) } else { dataRequest = session.request( url, method: method, encoding: method == .get ? URLEncoding.default : JSONEncoding.default ) + .validate(statusCode: 200..<300) } 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 @@ -172,7 +155,33 @@ class NetworkManager { } case .failure(let error): print("NetworkManager - Network error: \(error)") - continuation.resume(throwing: NetworkError.networkError(error)) + + if let data = response.data { + Task { @MainActor in + if let errorResponse = self.decodeApiErrorResponse(from: data) { + let statusCode = errorResponse.code ?? response.response?.statusCode ?? -1 + continuation.resume(throwing: NetworkError.serverError(statusCode, message: errorResponse.message)) + return + } + + if let httpResponse = response.response, + let apiResponse = self.decodeEmptyApiResponse(from: data) { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: apiResponse.message)) + return + } + + if let httpResponse = response.response { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + return + } + + continuation.resume(throwing: NetworkError.networkError(error)) + } + } else if let httpResponse = response.response { + continuation.resume(throwing: NetworkError.serverError(httpResponse.statusCode, message: nil)) + } else { + continuation.resume(throwing: NetworkError.networkError(error)) + } } } } diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 4782e7f..86ed148 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -20,9 +20,10 @@ struct StringResources { static let greetingSuffix = " 님" static let intro = "오늘도 효율적인 재고 관리를 시작해보세요." static let employee = "직원 관리" + static let partsAll = "총 부품" + static let partsOutOfStock = "품절 부품" + static let partLowStock = "부족 부품" static let partsOnHand = "보유 부품" - static let partsInProgress = "진행중 부품" - static let shortageOfParts = "부족 부품" static let orderAmount = "주문 금액" static let recentOrdersTitle = "최근 주문" static let weeklySummaryTitle = "이번 주 요약" diff --git a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift index 5691676..fac904e 100644 --- a/SampoomManagement/Features/Auth/UI/AuthViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/AuthViewModel.swift @@ -34,7 +34,9 @@ class AuthViewModel: ObservableObject { // MARK: - Actions func updateLoginState() { - isLoggedIn = checkLoginStateUseCase.execute() + Task { @MainActor in + isLoggedIn = checkLoginStateUseCase.execute() + } } func signOut() async { diff --git a/SampoomManagement/Features/Auth/UI/LoginView.swift b/SampoomManagement/Features/Auth/UI/LoginView.swift index 7faa0b4..8569823 100644 --- a/SampoomManagement/Features/Auth/UI/LoginView.swift +++ b/SampoomManagement/Features/Auth/UI/LoginView.swift @@ -111,7 +111,13 @@ struct LoginView: View { } .onChange(of: viewModel.uiState.success) { _, success in if success { - onSuccess() + Task { @MainActor in + // 상태 업데이트가 완료될 때까지 약간의 딜레이 + try? await Task.sleep(nanoseconds: 50_000_000) // 0.05초 + onSuccess() + // 다음 로그인을 위해 success 상태 리셋 + viewModel.uiState = viewModel.uiState.copy(success: false) + } } } } diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift index c14ea4f..d14cbb1 100644 --- a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -51,7 +51,7 @@ class LoginViewModel: ObservableObject { // 로그인 성공 후 프로필 조회 do { _ = try await getProfileUseCase.execute(workspace: "AGENCY") - uiState = uiState.copy(loading: false, success: true) + uiState = uiState.copy(loading: false, success: true) } catch { uiState = uiState.copy(loading: false) showError(error.localizedDescription) diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift index 4e94b1f..b6552cd 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -25,7 +25,7 @@ struct DashboardView: View { .frame(height: 24) Spacer() HStack(spacing: 12) { - if let user = viewModel.user, user.role.isAdmin { + if let user = viewModel.user, isManagerOrAbove(user.position) { Button(action: onEmployeeClick) { Image("employee").renderingMode(.template).foregroundStyle(.text) } @@ -43,6 +43,7 @@ struct DashboardView: View { titleSection buttonSection orderListSection + Spacer(minLength: 32) weeklySummarySection Spacer(minLength: 100) } @@ -81,17 +82,17 @@ struct DashboardView: View { private var buttonSection: some View { let dash = viewModel.uiState.dashboard return VStack(spacing: 16) { - if let user = viewModel.user, user.role.isAdmin { + if let user = viewModel.user, isManagerOrAbove(user.position) { let employeeValueText = viewModel.uiState.employeeCount .map { String($0) } ?? StringResources.Common.slash buttonCard(iconName: "employee", valueText: employeeValueText, subText: StringResources.Dashboard.employee, bordered: true, onClick: onEmployeeClick) } 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) + buttonCard(iconName: "car", valueText: String(dash?.totalParts ?? 0), subText: StringResources.Dashboard.partsAll) + buttonCard(iconName: "block", valueText: String(dash?.outOfStockParts ?? 0), subText: StringResources.Dashboard.partsOutOfStock) } HStack(spacing: 16) { - buttonCard(iconName: "warning", valueText: String(dash?.lowStockParts ?? 0), subText: StringResources.Dashboard.shortageOfParts) + buttonCard(iconName: "warning", valueText: String(dash?.lowStockParts ?? 0), subText: StringResources.Dashboard.partLowStock) buttonCard(iconName: "parts", valueText: String(dash?.totalQuantity ?? 0), subText: StringResources.Dashboard.partsOnHand) } } @@ -109,6 +110,7 @@ struct DashboardView: View { .clipShape(Circle()) Text(valueText) .font(.gmarketTitle2) + .fontWeight(.bold) .foregroundColor(.text) Text(subText) .font(.gmarketBody) @@ -191,6 +193,15 @@ struct DashboardView: View { .background(Color.backgroundCard) .clipShape(RoundedRectangle(cornerRadius: 16)) } + + private func isManagerOrAbove(_ position: UserPosition) -> Bool { + switch position { + case .manager, .deputyGeneralManager, .generalManager, .director, .vicePresident, .president, .chairman: + return true + default: + return false + } + } } diff --git a/SampoomManagement/Features/Order/UI/OrderDetailContent.swift b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift index 60c5879..f52f3bf 100644 --- a/SampoomManagement/Features/Order/UI/OrderDetailContent.swift +++ b/SampoomManagement/Features/Order/UI/OrderDetailContent.swift @@ -52,25 +52,16 @@ struct OrderInfoCard: View { 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) @@ -82,9 +73,6 @@ struct OrderInfoCard: View { } .padding(16) - Divider() - .padding(.horizontal, 16) - HStack { Text(StringResources.Order.detailTotalAmount) .font(.gmarketBody) @@ -168,8 +156,10 @@ struct OrderPartItem: View { } .padding(16) - Divider() - .padding(.horizontal, 16) + VStack { + Divider().background(.textSecondary) + } + .padding(.horizontal, 16) HStack { Spacer() diff --git a/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift b/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift index 73987fa..254dbda 100644 --- a/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift +++ b/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift @@ -79,8 +79,8 @@ extension EmployeeDTO { workspace: self.workspace, organizationId: self.organizationId, branch: self.branch, - position: self.position, - status: self.status ?? .active, + position: UserPosition(rawValue: self.position) ?? .staff, + status: EmployeeStatus(rawValue: (self.status ?? "").uppercased()) ?? .active, createdAt: self.createdAt, startedAt: self.startedAt, endedAt: self.endedAt, diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift index f161a75..2b9d3bf 100644 --- a/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift +++ b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift @@ -15,8 +15,8 @@ struct EmployeeDTO: Codable { let workspace: String let organizationId: Int let branch: String - let position: UserPosition - let status: EmployeeStatus? + let position: String + let status: String? let createdAt: String? let startedAt: String? let endedAt: String?