Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions SampoomManagement/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}
}
30 changes: 24 additions & 6 deletions SampoomManagement/Core/Network/AuthRequestInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
import Foundation
import Alamofire

extension Notification.Name {
static let didRequestLogout = Notification.Name("didRequestLogout")
}

// 비동기-세이프 토큰 갱신 조정자
actor RefreshCoordinator {
private var inFlight: Task<User, Error>?
Expand Down Expand Up @@ -39,6 +43,13 @@ final class AuthRequestInterceptor: RequestInterceptor, @unchecked Sendable {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> 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")
Expand All @@ -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)
Expand All @@ -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)
}
}
Expand Down
113 changes: 61 additions & 52 deletions SampoomManagement/Core/Network/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
}
}
Expand All @@ -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
Expand All @@ -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))
}
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions SampoomManagement/Core/Resources/StringResources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "이번 주 요약"
Expand Down
4 changes: 3 additions & 1 deletion SampoomManagement/Features/Auth/UI/AuthViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ class AuthViewModel: ObservableObject {

// MARK: - Actions
func updateLoginState() {
isLoggedIn = checkLoginStateUseCase.execute()
Task { @MainActor in
isLoggedIn = checkLoginStateUseCase.execute()
}
}

func signOut() async {
Expand Down
8 changes: 7 additions & 1 deletion SampoomManagement/Features/Auth/UI/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion SampoomManagement/Features/Auth/UI/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 16 additions & 5 deletions SampoomManagement/Features/Dashboard/UI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -43,6 +43,7 @@ struct DashboardView: View {
titleSection
buttonSection
orderListSection
Spacer(minLength: 32)
weeklySummarySection
Spacer(minLength: 100)
}
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -109,6 +110,7 @@ struct DashboardView: View {
.clipShape(Circle())
Text(valueText)
.font(.gmarketTitle2)
.fontWeight(.bold)
.foregroundColor(.text)
Text(subText)
.font(.gmarketBody)
Expand Down Expand Up @@ -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
}
}
}


18 changes: 4 additions & 14 deletions SampoomManagement/Features/Order/UI/OrderDetailContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -82,9 +73,6 @@ struct OrderInfoCard: View {
}
.padding(16)

Divider()
.padding(.horizontal, 16)

HStack {
Text(StringResources.Order.detailTotalAmount)
.font(.gmarketBody)
Expand Down Expand Up @@ -168,8 +156,10 @@ struct OrderPartItem: View {
}
.padding(16)

Divider()
.padding(.horizontal, 16)
VStack {
Divider().background(.textSecondary)
}
.padding(.horizontal, 16)

HStack {
Spacer()
Expand Down
Loading