diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index 6e49bfc..b46060b 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -15,6 +15,10 @@ enum SettingNavigation: Hashable { case settings } +enum EmployeeNavigation: Hashable { + case employeeList +} + struct ContentView: View { // MARK: - Properties let dependencies: AppDependencies @@ -43,7 +47,6 @@ struct ContentView: View { // Dashboard 탭 (DashboardView directly) Tab(value: .dashboard) { NavigationStack(path: $dashboardNavigationPath) { - let user = try? dependencies.authPreferences.getStoredUser() DashboardView( viewModel: dashboardViewModel, onLogoutClick: { @@ -61,15 +64,16 @@ struct ContentView: View { onSettingClick: { dashboardNavigationPath.append(SettingNavigation.settings) }, - userName: user?.name ?? "", - branch: user?.branch ?? "", - userRole: user?.role ?? .user + onEmployeeClick: { + dashboardNavigationPath.append(EmployeeNavigation.employeeList) + } ) .navigationDestination(for: SettingNavigation.self) { destination in switch destination { case .settings: SettingView( viewModel: dependencies.makeSettingViewModel(), + updateProfileViewModel: dependencies.makeUpdateProfileViewModel(), onNavigateBack: { if !dashboardNavigationPath.isEmpty { dashboardNavigationPath.removeLast() @@ -81,6 +85,21 @@ struct ContentView: View { ) } } + .navigationDestination(for: EmployeeNavigation.self) { destination in + switch destination { + case .employeeList: + EmployeeListView( + viewModel: dependencies.makeEmployeeListViewModel(), + editEmployeeViewModel: dependencies.makeEditEmployeeViewModel(), + updateEmployeeStatusViewModel: dependencies.makeUpdateEmployeeStatusViewModel(), + onNavigateBack: { + if !dashboardNavigationPath.isEmpty { + dashboardNavigationPath.removeLast() + } + } + ) + } + } } } label: { Label { @@ -184,7 +203,7 @@ struct ContentView: View { } } } - .accentColor(.accentColor) + .accentColor(.accent) .tabViewStyle(.automatic) } } diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index 55b9bfa..c0f78aa 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -73,6 +73,17 @@ class AppDependencies { let getDashboardUseCase: GetDashboardUseCase let getWeeklySummaryUseCase: GetWeeklySummaryUseCase + // MARK: - User + let userAPI: UserAPI + let userRepository: UserRepository + let getProfileUseCase: GetProfileUseCase + let getStoredUserUseCase: GetStoredUserUseCase + let updateProfileUseCase: UpdateProfileUseCase + let getEmployeeUseCase: GetEmployeeUseCase + let editEmployeeUseCase: EditEmployeeUseCase + let getEmployeeCountUseCase: GetEmployeeCountUseCase + let updateEmployeeStatusUseCase: UpdateEmployeeStatusUseCase + init() { // Global Message Handler globalMessageHandler = GlobalMessageHandler.shared @@ -152,16 +163,27 @@ class AppDependencies { dashboardRepository = DashboardRepositoryImpl(api: dashboardAPI, authPreferences: authPreferences) getDashboardUseCase = GetDashboardUseCase(repository: dashboardRepository) getWeeklySummaryUseCase = GetWeeklySummaryUseCase(repository: dashboardRepository) + + // User + userAPI = UserAPI(networkManager: networkManager) + userRepository = UserRepositoryImpl(api: userAPI, preferences: authPreferences) + getProfileUseCase = GetProfileUseCase(repository: userRepository) + getStoredUserUseCase = GetStoredUserUseCase(repository: userRepository) + updateProfileUseCase = UpdateProfileUseCase(repository: userRepository) + getEmployeeUseCase = GetEmployeeUseCase(repository: userRepository) + editEmployeeUseCase = EditEmployeeUseCase(repository: userRepository) + getEmployeeCountUseCase = GetEmployeeCountUseCase(repository: userRepository) + updateEmployeeStatusUseCase = UpdateEmployeeStatusUseCase(repository: userRepository) } // MARK: - ViewModel Factories func makeLoginViewModel() -> LoginViewModel { - return LoginViewModel(loginUseCase: loginUseCase) + return LoginViewModel(loginUseCase: loginUseCase, getProfileUseCase: getProfileUseCase) } func makeSignUpViewModel() -> SignUpViewModel { - return SignUpViewModel(signUpUseCase: signUpUseCase, getVendorUseCase: getVendorUseCase) + return SignUpViewModel(signUpUseCase: signUpUseCase, getVendorUseCase: getVendorUseCase, getProfileUseCase: getProfileUseCase) } func makePartViewModel() -> PartViewModel { @@ -229,19 +251,50 @@ class AppDependencies { ) } + func makeDashboardViewModel() -> DashboardViewModel { + return DashboardViewModel( + getOrderUseCase: getOrderUseCase, + getDashboardUseCase: getDashboardUseCase, + getWeeklySummaryUseCase: getWeeklySummaryUseCase, + getStoredUserUseCase: getStoredUserUseCase, + getEmployeeCountUseCase: getEmployeeCountUseCase, + messageHandler: globalMessageHandler + ) + } + func makeSettingViewModel() -> SettingViewModel { return SettingViewModel( - authPreferences: authPreferences, + getStoredUserUseCase: getStoredUserUseCase, signOutUseCase: signOutUseCase, globalMessageHandler: globalMessageHandler ) } - func makeDashboardViewModel() -> DashboardViewModel { - return DashboardViewModel( - getOrderUseCase: getOrderUseCase, - getDashboardUseCase: getDashboardUseCase, - getWeeklySummaryUseCase: getWeeklySummaryUseCase, + func makeEmployeeListViewModel() -> EmployeeListViewModel { + return EmployeeListViewModel( + getEmployeeUseCase: getEmployeeUseCase, + messageHandler: globalMessageHandler, + authPreferences: authPreferences + ) + } + + func makeEditEmployeeViewModel() -> EditEmployeeViewModel { + return EditEmployeeViewModel( + editEmployeeUseCase: editEmployeeUseCase, + messageHandler: globalMessageHandler + ) + } + + func makeUpdateEmployeeStatusViewModel() -> UpdateEmployeeStatusViewModel { + return UpdateEmployeeStatusViewModel( + updateEmployeeStatusUseCase: updateEmployeeStatusUseCase, + messageHandler: globalMessageHandler + ) + } + + func makeUpdateProfileViewModel() -> UpdateProfileViewModel { + return UpdateProfileViewModel( + updateProfileUseCase: updateProfileUseCase, messageHandler: globalMessageHandler ) } diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index b2b7615..4782e7f 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -192,8 +192,38 @@ struct StringResources { struct Setting { static let title = "설정" static let editProfile = "프로필 수정" + static let editProfilePlaceholderUsername = "이름 입력" + static let editProfileEdited = "프로필 수정이 완료되었습니다." static let logout = "로그아웃" + static let logoutTitle = "로그아웃" static let dialogLogout = "로그아웃 하시겠습니까?" + static let userNotFound = "사용자 정보를 찾을 수 없습니다" + } + + // MARK: - Employee + struct Employee { + static let title = "직원관리" + static let emptyEmployee = "직원이 없습니다." + static let email = "이메일" + static let createdAt = "최초생성일" + static let startedAt = "근무시작일" + static let endedAt = "근무종료일" + static let deletedAt = "퇴사일" + static let delete = "삭제" + static let edit = "수정" + static let editTitle = "직원 수정" + static let positionLabel = "직급" + static let editEdited = "직원 수정이 완료되었습니다." + static let editDeleted = "직원 삭제가 완료되었습니다." + static let statusLabel = "재직상태" + static let statusEdit = "재직상태 변경" + static let positionEdit = "직급 변경" + static let editStatusEdited = "직원 상태 수정이 완료되었습니다." + static let statusActive = "재직" + static let statusLeave = "휴직" + static let statusRetired = "퇴직" + static let employeeNotFound = "직원 정보를 찾을 수 없습니다" + static let userNotFound = "사용자 정보를 찾을 수 없습니다" } // MARK: - Auth diff --git a/SampoomManagement/Core/UI/Components/CommonButton.swift b/SampoomManagement/Core/UI/Components/CommonButton.swift index 28c465e..3d5013e 100644 --- a/SampoomManagement/Core/UI/Components/CommonButton.swift +++ b/SampoomManagement/Core/UI/Components/CommonButton.swift @@ -11,6 +11,7 @@ import SwiftUI enum ButtonType { case filled // 채워진 버튼 case outlined // 테두리만 있는 버튼 + case secondary // 톤 다운된 배경이 있는 버튼 } // MARK: - Button Sizes @@ -134,6 +135,8 @@ struct CommonButton: View { return Color(.accent) // 기본 보라색 case .outlined: return .clear + case .secondary: + return Color(.accent).opacity(0.3) } } @@ -150,7 +153,9 @@ struct CommonButton: View { case .filled: return .white case .outlined: - return borderColor ?? .blue + return borderColor ?? Color(.accent) + case .secondary: + return textColor ?? Color(.accent) } } @@ -167,7 +172,9 @@ struct CommonButton: View { case .filled: return .clear case .outlined: - return .blue + return borderColor ?? Color(.accent) + case .secondary: + return borderColor ?? Color(.accent) } } @@ -177,6 +184,8 @@ struct CommonButton: View { return 0 case .outlined: return 1 + case .secondary: + return 1 } } } diff --git a/SampoomManagement/Features/Auth/Domain/AuthValidator.swift b/SampoomManagement/Core/Utilities/AuthValidator.swift similarity index 99% rename from SampoomManagement/Features/Auth/Domain/AuthValidator.swift rename to SampoomManagement/Core/Utilities/AuthValidator.swift index 3b651f5..3edd0bd 100644 --- a/SampoomManagement/Features/Auth/Domain/AuthValidator.swift +++ b/SampoomManagement/Core/Utilities/AuthValidator.swift @@ -69,4 +69,3 @@ class AuthValidator { } } - diff --git a/SampoomManagement/Core/Utilities/EmployeeStatusFormatter.swift b/SampoomManagement/Core/Utilities/EmployeeStatusFormatter.swift new file mode 100644 index 0000000..0d7d91e --- /dev/null +++ b/SampoomManagement/Core/Utilities/EmployeeStatusFormatter.swift @@ -0,0 +1,15 @@ +// +// EmployeeStatusFormatter.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +func employeeStatusToKorean(_ status: EmployeeStatus?) -> String { + guard let status else { return StringResources.Common.slash } + return status.displayNameKo +} + + diff --git a/SampoomManagement/Features/Auth/Domain/ValidationResult.swift b/SampoomManagement/Core/Utilities/ValidationResult.swift similarity index 99% rename from SampoomManagement/Features/Auth/Domain/ValidationResult.swift rename to SampoomManagement/Core/Utilities/ValidationResult.swift index 5e10ad1..4d7281f 100644 --- a/SampoomManagement/Features/Auth/Domain/ValidationResult.swift +++ b/SampoomManagement/Core/Utilities/ValidationResult.swift @@ -26,4 +26,3 @@ enum ValidationResult { } } - diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift index 3a53984..92146c0 100644 --- a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -27,45 +27,6 @@ extension LoginResponseDTO { } } -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 diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift index 6b880fb..f57649f 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -102,15 +102,5 @@ class AuthAPI { 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/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift index 3377bd8..7271c68 100644 --- a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -56,42 +56,15 @@ class AuthRepositoryImpl: AuthRepository { throw AuthError.invalidResponse } let loginUser = loginDto.toModel() - // Store tokens immediately so that subsequent authorized calls (e.g., getProfile) carry Authorization header + // Store tokens immediately so that subsequent authorized calls carry Authorization header do { - try preferences.saveToken(accessToken: loginUser.accessToken, refreshToken: loginUser.refreshToken) + try preferences.saveUser(loginUser) } 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 + return loginUser } func signOut() async throws { diff --git a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift index 0671667..c14ea4f 100644 --- a/SampoomManagement/Features/Auth/UI/LoginViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/LoginViewModel.swift @@ -14,9 +14,11 @@ class LoginViewModel: ObservableObject { @Published var uiState = LoginUiState() private let loginUseCase: LoginUseCase + private let getProfileUseCase: GetProfileUseCase - init(loginUseCase: LoginUseCase) { + init(loginUseCase: LoginUseCase, getProfileUseCase: GetProfileUseCase) { self.loginUseCase = loginUseCase + self.getProfileUseCase = getProfileUseCase } // 이메일 업데이트 @@ -46,7 +48,14 @@ class LoginViewModel: ObservableObject { do { _ = try await loginUseCase.execute(email: email, password: password) - uiState = uiState.copy(loading: false, success: true) + // 로그인 성공 후 프로필 조회 + do { + _ = try await getProfileUseCase.execute(workspace: "AGENCY") + uiState = uiState.copy(loading: false, success: true) + } catch { + uiState = uiState.copy(loading: false) + showError(error.localizedDescription) + } } catch { uiState = uiState.copy(loading: false) showError(error.localizedDescription) diff --git a/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift index 6a43df3..7e28f56 100644 --- a/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift +++ b/SampoomManagement/Features/Auth/UI/SignUpViewModel.swift @@ -15,10 +15,12 @@ class SignUpViewModel: ObservableObject { private let signUpUseCase: SignUpUseCase private let getVendorUseCase: GetVendorUseCase + private let getProfileUseCase: GetProfileUseCase - init(signUpUseCase: SignUpUseCase, getVendorUseCase: GetVendorUseCase) { + init(signUpUseCase: SignUpUseCase, getVendorUseCase: GetVendorUseCase, getProfileUseCase: GetProfileUseCase) { self.signUpUseCase = signUpUseCase self.getVendorUseCase = getVendorUseCase + self.getProfileUseCase = getProfileUseCase Task { await loadVendors() } } @@ -97,6 +99,8 @@ class SignUpViewModel: ObservableObject { email: email, password: password ) + // 회원가입 성공 후 프로필 조회 + _ = try await getProfileUseCase.execute(workspace: "AGENCY") uiState = uiState.copy(loading: false, success: true) } catch { uiState = uiState.copy(loading: false) diff --git a/SampoomManagement/Features/Cart/UI/CartListView.swift b/SampoomManagement/Features/Cart/UI/CartListView.swift index d1562f3..9a5fc72 100644 --- a/SampoomManagement/Features/Cart/UI/CartListView.swift +++ b/SampoomManagement/Features/Cart/UI/CartListView.swift @@ -106,7 +106,7 @@ struct CartListView: View { private var orderButton: some View { VStack { Spacer() - CommonButton("\(formatWon(viewModel.uiState.totalCost)) \(StringResources.Cart.processOrder)", backgroundColor: .accent, textColor: .white) { + CommonButton("\(formatWon(viewModel.uiState.totalCost)) \(StringResources.Cart.processOrder)", type: .filled) { showConfirmDialog = true } .padding(.horizontal, 16) diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift index a3bde4b..1135796 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift @@ -15,6 +15,9 @@ struct DashboardUiState: Equatable { let dashboardError: String? let weeklySummaryLoading: Bool let weeklySummaryError: String? + let employeeCount: Int? + let employeeCountLoading: Bool + let employeeCountError: String? init( orderList: [Order] = [], @@ -23,7 +26,10 @@ struct DashboardUiState: Equatable { dashboardLoading: Bool = false, dashboardError: String? = nil, weeklySummaryLoading: Bool = false, - weeklySummaryError: String? = nil + weeklySummaryError: String? = nil, + employeeCount: Int? = nil, + employeeCountLoading: Bool = false, + employeeCountError: String? = nil ) { self.orderList = orderList self.dashboard = dashboard @@ -32,6 +38,9 @@ struct DashboardUiState: Equatable { self.dashboardError = dashboardError self.weeklySummaryLoading = weeklySummaryLoading self.weeklySummaryError = weeklySummaryError + self.employeeCount = employeeCount + self.employeeCountLoading = employeeCountLoading + self.employeeCountError = employeeCountError } func copy( @@ -41,7 +50,10 @@ struct DashboardUiState: Equatable { dashboardLoading: Bool? = nil, dashboardError: String?? = nil, weeklySummaryLoading: Bool? = nil, - weeklySummaryError: String?? = nil + weeklySummaryError: String?? = nil, + employeeCount: Int?? = nil, + employeeCountLoading: Bool? = nil, + employeeCountError: String?? = nil ) -> DashboardUiState { return DashboardUiState( orderList: orderList ?? self.orderList, @@ -50,7 +62,10 @@ struct DashboardUiState: Equatable { dashboardLoading: dashboardLoading ?? self.dashboardLoading, dashboardError: dashboardError ?? self.dashboardError, weeklySummaryLoading: weeklySummaryLoading ?? self.weeklySummaryLoading, - weeklySummaryError: weeklySummaryError ?? self.weeklySummaryError + weeklySummaryError: weeklySummaryError ?? self.weeklySummaryError, + employeeCount: employeeCount ?? self.employeeCount, + employeeCountLoading: employeeCountLoading ?? self.employeeCountLoading, + employeeCountError: employeeCountError ?? self.employeeCountError ) } } diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift index d8e5ceb..4e94b1f 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -13,9 +13,7 @@ struct DashboardView: View { let onNavigateOrderDetail: (Order) -> Void let onNavigateOrderList: () -> Void let onSettingClick: () -> Void - let userName: String - let branch: String - let userRole: UserRole + let onEmployeeClick: () -> Void var body: some View { VStack(spacing: 0) { @@ -27,8 +25,8 @@ struct DashboardView: View { .frame(height: 24) Spacer() HStack(spacing: 12) { - if userRole.isAdmin { - Button(action: {}) { + if let user = viewModel.user, user.role.isAdmin { + Button(action: onEmployeeClick) { Image("employee").renderingMode(.template).foregroundStyle(.text) } } @@ -54,18 +52,19 @@ struct DashboardView: View { .background(Color.background) .refreshable { viewModel.onEvent(.loadDashboard) + viewModel.refreshUser() } } private var titleSection: some View { VStack(alignment: .leading, spacing: 16) { - Text("\(branch)") + Text(viewModel.user?.branch ?? "") .font(.gmarketTitle2) .fontWeight(.bold) .foregroundColor(.text) Group { - Text(StringResources.Dashboard.greetingPrefix) + Text(userName).foregroundColor(.accent) + Text(StringResources.Dashboard.greetingSuffix) + Text(StringResources.Dashboard.greetingPrefix) + Text(viewModel.user?.name ?? "").foregroundColor(.accent) + Text(StringResources.Dashboard.greetingSuffix) } .font(.gmarketTitle) .fontWeight(.bold) @@ -82,23 +81,25 @@ struct DashboardView: View { 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) {} + if let user = viewModel.user, user.role.isAdmin { + 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.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) {} + 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) { + private func buttonCard(iconName: String, valueText: String, subText: String, bordered: Bool = false, onClick: (() -> Void)? = nil) -> some View { + Button(action: onClick ?? {}) { VStack(alignment: .center, spacing: 16) { Image(iconName) .renderingMode(.template) @@ -119,7 +120,7 @@ struct DashboardView: View { .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) - .stroke(bordered ? Color.accentColor : Color.clear, lineWidth: 1) + .stroke(bordered ? .accent : Color.clear, lineWidth: 1) ) } } diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift index 2de2522..5b5c8b0 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift @@ -12,25 +12,47 @@ import Combine @MainActor class DashboardViewModel: ObservableObject { @Published var uiState = DashboardUiState() + @Published var user: User? = nil private let getOrderUseCase: GetOrderUseCase private let getDashboardUseCase: GetDashboardUseCase private let getWeeklySummaryUseCase: GetWeeklySummaryUseCase + private let getStoredUserUseCase: GetStoredUserUseCase + private let getEmployeeCountUseCase: GetEmployeeCountUseCase private let messageHandler: GlobalMessageHandler init( getOrderUseCase: GetOrderUseCase, getDashboardUseCase: GetDashboardUseCase, getWeeklySummaryUseCase: GetWeeklySummaryUseCase, + getStoredUserUseCase: GetStoredUserUseCase, + getEmployeeCountUseCase: GetEmployeeCountUseCase, messageHandler: GlobalMessageHandler ) { self.getOrderUseCase = getOrderUseCase self.getDashboardUseCase = getDashboardUseCase self.getWeeklySummaryUseCase = getWeeklySummaryUseCase + self.getStoredUserUseCase = getStoredUserUseCase + self.getEmployeeCountUseCase = getEmployeeCountUseCase self.messageHandler = messageHandler loadAll() } + func refreshUser() { + loadUser() + Task { await loadEmployeeCount() } + } + + private func loadUser() { + Task { + do { + user = try getStoredUserUseCase.execute() + } catch { + print("DashboardViewModel - 사용자 정보 조회 실패: \(error)") + } + } + } + func onEvent(_ event: DashboardUiEvent) { switch event { case .loadDashboard, .retryDashboard: @@ -40,8 +62,10 @@ class DashboardViewModel: ObservableObject { private func loadAll() { loadOrderList() + loadUser() Task { await loadDashboard() } Task { await loadWeeklySummary() } + Task { await loadEmployeeCount() } } private func loadOrderList() { @@ -93,6 +117,39 @@ class DashboardViewModel: ObservableObject { ) } } + + private func loadEmployeeCount() async { + uiState = uiState.copy(employeeCountLoading: true, employeeCountError: .some(nil)) + + do { + guard let storedUser = try getStoredUserUseCase.execute() else { + uiState = uiState.copy( + employeeCountLoading: false, + employeeCountError: .some(StringResources.Employee.userNotFound) + ) + return + } + let workspace = storedUser.workspace.isEmpty ? "AGENCY" : storedUser.workspace + + let count = try await getEmployeeCountUseCase.execute( + workspace: workspace, + organizationId: storedUser.agencyId + ) + + uiState = uiState.copy( + employeeCount: .some(count), + employeeCountLoading: false, + employeeCountError: .some(nil) + ) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + messageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy( + employeeCountLoading: false, + employeeCountError: .some(errorMessage) + ) + } + } } diff --git a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift index 90d4ab7..9ae02f1 100644 --- a/SampoomManagement/Features/Outbound/UI/OutboundListView.swift +++ b/SampoomManagement/Features/Outbound/UI/OutboundListView.swift @@ -137,7 +137,7 @@ struct OutboundListView: View { // 출고 주문 버튼 VStack { Spacer() - CommonButton("\(formatWon(viewModel.uiState.totalCost)) \(StringResources.Outbound.processOrder)", backgroundColor: .red, textColor: .white) { + CommonButton("\(formatWon(viewModel.uiState.totalCost)) \(StringResources.Outbound.processOrder)", type: .secondary) { showConfirmDialog = true } .padding(.horizontal, 16) diff --git a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift index 5a2c10b..493a433 100644 --- a/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift +++ b/SampoomManagement/Features/Part/UI/PartDetailBottomSheetView.swift @@ -189,15 +189,15 @@ 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) { + CommonButton(StringResources.PartDetail.addToOutbound, type: .secondary, customIcon: "outbound") { addOutboundAction() } .disabled(isDisabled) - - CommonButton(StringResources.PartDetail.addToCart, customIcon: "cart", backgroundColor: .accent, textColor: .white) { + + CommonButton(StringResources.PartDetail.addToCart, type: .filled, customIcon: "cart") { addCartAction() } .disabled(isDisabled) diff --git a/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift b/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift index 9ed9b3f..fe3d618 100644 --- a/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift +++ b/SampoomManagement/Features/Setting/UI/SettingUiEvent.swift @@ -9,7 +9,5 @@ import Foundation enum SettingUiEvent { case loadProfile - case editProfile - case logout } diff --git a/SampoomManagement/Features/Setting/UI/SettingView.swift b/SampoomManagement/Features/Setting/UI/SettingView.swift index 1e51b1a..6018b23 100644 --- a/SampoomManagement/Features/Setting/UI/SettingView.swift +++ b/SampoomManagement/Features/Setting/UI/SettingView.swift @@ -9,9 +9,11 @@ import SwiftUI struct SettingView: View { @ObservedObject var viewModel: SettingViewModel + @ObservedObject var updateProfileViewModel: UpdateProfileViewModel let onNavigateBack: () -> Void let onLogoutClick: () -> Void @State private var showLogoutDialog = false + @State private var showEditProfileSheet = false var body: some View { VStack(spacing: 0) { @@ -37,7 +39,23 @@ struct SettingView: View { .onAppear { viewModel.onEvent(.loadProfile) } - .alert("로그아웃", isPresented: $showLogoutDialog) { + .sheet(isPresented: $showEditProfileSheet) { + if let user = viewModel.user { + UpdateProfileBottomSheet( + user: user, + viewModel: updateProfileViewModel, + onProfileUpdated: { updatedUser in + viewModel.refreshUser() + }, + onDismiss: { + showEditProfileSheet = false + } + ) + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + } + } + .alert(StringResources.Setting.logoutTitle, isPresented: $showLogoutDialog) { Button(StringResources.Common.cancel, role: .cancel) {} Button(StringResources.Common.confirm) { Task { @@ -89,8 +107,7 @@ struct SettingView: View { private func settingSection() -> some View { VStack(spacing: 8) { Button(action: { - // TODO: Edit profile - viewModel.onEvent(.editProfile) + showEditProfileSheet = true }) { HStack { Text(StringResources.Setting.editProfile) @@ -111,7 +128,7 @@ struct SettingView: View { HStack { Text(StringResources.Setting.logout) .font(.gmarketBody) - .foregroundColor(.text) + .foregroundColor(.failRed) Spacer() Image(systemName: "chevron.right") .foregroundColor(.textSecondary) diff --git a/SampoomManagement/Features/Setting/UI/SettingViewModel.swift b/SampoomManagement/Features/Setting/UI/SettingViewModel.swift index 431faae..2388385 100644 --- a/SampoomManagement/Features/Setting/UI/SettingViewModel.swift +++ b/SampoomManagement/Features/Setting/UI/SettingViewModel.swift @@ -14,16 +14,16 @@ class SettingViewModel: ObservableObject { @Published var uiState = SettingUiState.initial @Published var user: User? - private let authPreferences: AuthPreferences + private let getStoredUserUseCase: GetStoredUserUseCase private let signOutUseCase: SignOutUseCase private let globalMessageHandler: GlobalMessageHandler init( - authPreferences: AuthPreferences, + getStoredUserUseCase: GetStoredUserUseCase, signOutUseCase: SignOutUseCase, globalMessageHandler: GlobalMessageHandler ) { - self.authPreferences = authPreferences + self.getStoredUserUseCase = getStoredUserUseCase self.signOutUseCase = signOutUseCase self.globalMessageHandler = globalMessageHandler } @@ -31,21 +31,17 @@ class SettingViewModel: ObservableObject { 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 + refreshUser() } } - private func loadProfile() { - do { - user = try authPreferences.getStoredUser() - } catch { - uiState = uiState.copy(error: error.localizedDescription) + func refreshUser() { + Task { + do { + user = try getStoredUserUseCase.execute() + } catch { + uiState = uiState.copy(error: error.localizedDescription) + } } } diff --git a/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift b/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift new file mode 100644 index 0000000..73987fa --- /dev/null +++ b/SampoomManagement/Features/User/Data/Mappers/UserMappers.swift @@ -0,0 +1,113 @@ +// +// UserMappers.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +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 UpdateProfileResponseDTO { + func toModel() -> User { + return User( + id: self.userId, + name: self.userName, + email: "", + role: .user, + accessToken: "", + refreshToken: "", + expiresIn: 0, + position: .staff, + workspace: "", + branch: "", + agencyId: 0, + startedAt: nil, + endedAt: nil + ) + } +} + +extension EditEmployeeResponseDTO { + func toModel() -> Employee { + return Employee( + id: self.userId, + userId: self.userId, + email: "", + role: "", + userName: self.userName, + workspace: self.workspace, + organizationId: 0, + branch: "", + position: UserPosition(rawValue: self.position) ?? .staff, + status: .active, + createdAt: nil, + startedAt: nil, + endedAt: nil, + deletedAt: nil + ) + } +} + +extension EmployeeDTO { + func toModel() -> Employee { + return Employee( + id: self.userId, + userId: self.userId, + email: self.email, + role: self.role, + userName: self.userName, + workspace: self.workspace, + organizationId: self.organizationId, + branch: self.branch, + position: self.position, + status: self.status ?? .active, + createdAt: self.createdAt, + startedAt: self.startedAt, + endedAt: self.endedAt, + deletedAt: self.deletedAt + ) + } +} + +extension UpdateEmployeeStatusResponseDTO { + func toModel(existingEmployee: Employee) -> Employee { + return Employee( + id: existingEmployee.id, + userId: self.userId, + email: existingEmployee.email, + role: existingEmployee.role, + userName: self.userName.isEmpty ? existingEmployee.userName : self.userName, + workspace: self.workspace.isEmpty ? existingEmployee.workspace : self.workspace, + organizationId: existingEmployee.organizationId, + branch: existingEmployee.branch, + position: existingEmployee.position, + status: EmployeeStatus(rawValue: self.employeeStatus.uppercased()) ?? existingEmployee.status, + createdAt: existingEmployee.createdAt, + startedAt: existingEmployee.startedAt, + endedAt: existingEmployee.endedAt, + deletedAt: existingEmployee.deletedAt + ) + } +} + + diff --git a/SampoomManagement/Features/User/Data/Remote/API/UserAPI.swift b/SampoomManagement/Features/User/Data/Remote/API/UserAPI.swift new file mode 100644 index 0000000..1159e79 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/API/UserAPI.swift @@ -0,0 +1,86 @@ +// +// UserAPI.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import Alamofire + +class UserAPI { + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + // 프로필 조회 + 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 + ) + } + + // 프로필 수정 + func updateProfile(userName: String) async throws -> APIResponse { + let requestDTO = UpdateProfileRequestDTO(userName: userName) + + let parameters: [String: Any] = [ + "userName": requestDTO.userName + ] + + return try await networkManager.request( + endpoint: "user/profile", + method: .patch, + parameters: parameters, + responseType: UpdateProfileResponseDTO.self + ) + } + + // 직원 목록 조회 + func getEmployeeList(workspace: String, organizationId: Int, page: Int = 0, size: Int = 20) async throws -> APIResponse { + return try await networkManager.request( + endpoint: "user/info?workspace=\(workspace)&organizationId=\(organizationId)&page=\(page)&size=\(size)", + method: .get, + parameters: nil, + responseType: EmployeeListDTO.self + ) + } + + // 직원 수정 + func editEmployee(userId: Int, workspace: String, position: String) async throws -> APIResponse { + let requestDTO = EditEmployeeRequestDTO(position: position) + + let parameters: [String: Any] = [ + "position": requestDTO.position + ] + + return try await networkManager.request( + endpoint: "user/profile/\(userId)?workspace=\(workspace)", + method: .patch, + parameters: parameters, + responseType: EditEmployeeResponseDTO.self + ) + } + + // 직원 상태 수정 + func updateEmployeeStatus(userId: Int, workspace: String, employeeStatus: String) async throws -> APIResponse { + let requestDTO = UpdateEmployeeStatusRequestDTO(employeeStatus: employeeStatus) + + let parameters: [String: Any] = [ + "employeeStatus": requestDTO.employeeStatus + ] + + return try await networkManager.request( + endpoint: "user/status/\(userId)?workspace=\(workspace)", + method: .patch, + parameters: parameters, + responseType: UpdateEmployeeStatusResponseDTO.self + ) + } +} + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeRequestDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeRequestDTO.swift new file mode 100644 index 0000000..ffb6e8e --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeRequestDTO.swift @@ -0,0 +1,13 @@ +// +// EditEmployeeRequestDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EditEmployeeRequestDTO: Codable { + let position: String +} + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeResponseDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeResponseDTO.swift new file mode 100644 index 0000000..c14a2b0 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/EditEmployeeResponseDTO.swift @@ -0,0 +1,16 @@ +// +// EditEmployeeResponseDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EditEmployeeResponseDTO: Codable { + let userId: Int + let userName: String + let workspace: String + let position: String +} + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift new file mode 100644 index 0000000..f161a75 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeDTO.swift @@ -0,0 +1,25 @@ +// +// EmployeeDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EmployeeDTO: Codable { + let userId: Int + let email: String + let role: String + let userName: String + let workspace: String + let organizationId: Int + let branch: String + let position: UserPosition + let status: EmployeeStatus? + let createdAt: String? + let startedAt: String? + let endedAt: String? + let deletedAt: String? +} + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeListDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeListDTO.swift new file mode 100644 index 0000000..cda2d4a --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/EmployeeListDTO.swift @@ -0,0 +1,23 @@ +// +// EmployeeListDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EmployeeListDTO: Codable { + let users: [EmployeeDTO] + let meta: EmployeeMetaDTO +} + +struct EmployeeMetaDTO: Codable { + let currentPage: Int + let totalPages: Int + let totalElements: Int + let size: Int + let hasNext: Bool + let hasPrevious: Bool +} + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/GetProfileResponseDTO.swift similarity index 99% rename from SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift rename to SampoomManagement/Features/User/Data/Remote/DTO/GetProfileResponseDTO.swift index 9c86aff..4d50250 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift +++ b/SampoomManagement/Features/User/Data/Remote/DTO/GetProfileResponseDTO.swift @@ -20,4 +20,3 @@ struct GetProfileResponseDTO: Codable { let endedAt: String? } - diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusRequestDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusRequestDTO.swift new file mode 100644 index 0000000..2afcef7 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusRequestDTO.swift @@ -0,0 +1,14 @@ +// +// UpdateEmployeeStatusRequestDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateEmployeeStatusRequestDTO: Encodable { + let employeeStatus: String +} + + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusResponseDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusResponseDTO.swift new file mode 100644 index 0000000..fac1297 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateEmployeeStatusResponseDTO.swift @@ -0,0 +1,17 @@ +// +// UpdateEmployeeStatusResponseDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateEmployeeStatusResponseDTO: Codable { + let userId: Int + let userName: String + let workspace: String + let employeeStatus: String +} + + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileRequestDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileRequestDTO.swift new file mode 100644 index 0000000..0b2b6fa --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileRequestDTO.swift @@ -0,0 +1,13 @@ +// +// UpdateProfileRequestDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateProfileRequestDTO: Codable { + let userName: String +} + diff --git a/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileResponseDTO.swift b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileResponseDTO.swift new file mode 100644 index 0000000..b54da20 --- /dev/null +++ b/SampoomManagement/Features/User/Data/Remote/DTO/UpdateProfileResponseDTO.swift @@ -0,0 +1,14 @@ +// +// UpdateProfileResponseDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateProfileResponseDTO: Codable { + let userId: Int + let userName: String +} + diff --git a/SampoomManagement/Features/User/Data/Repository/UserRepositoryImpl.swift b/SampoomManagement/Features/User/Data/Repository/UserRepositoryImpl.swift new file mode 100644 index 0000000..04ee16b --- /dev/null +++ b/SampoomManagement/Features/User/Data/Repository/UserRepositoryImpl.swift @@ -0,0 +1,183 @@ +// +// UserRepositoryImpl.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class UserRepositoryImpl: UserRepository { + private let api: UserAPI + private let preferences: AuthPreferences + + init(api: UserAPI, preferences: AuthPreferences) { + self.api = api + self.preferences = preferences + } + + func getStoredUser() throws -> User? { + return try preferences.getStoredUser() + } + + func getProfile(workspace: String) async throws -> User { + // 프로필 조회 (서버 반영 지연 고려하여 재시도) + let profileUser = try await retry(times: 5, initialDelayMs: 300, maxDelayMs: 1500, factor: 1.8) { + let profileResponse = try await self.api.getProfile(workspace: workspace) + guard let profileDto = profileResponse.data else { + throw NetworkError.serverError(profileResponse.status, message: profileResponse.message) + } + return profileDto.toModel() + } + + // 저장된 사용자 정보 조회 + guard let loginUser = try preferences.getStoredUser() else { + throw NetworkError.serverError(401, message: "No stored user") + } + + // 토큰 정보와 프로필 정보 병합 + let completeUser = User( + id: profileUser.id, + name: profileUser.name, + email: profileUser.email, + role: profileUser.role, + accessToken: loginUser.accessToken, + refreshToken: loginUser.refreshToken, + expiresIn: loginUser.expiresIn, + position: profileUser.position, + workspace: profileUser.workspace, + branch: profileUser.branch, + agencyId: profileUser.agencyId, + startedAt: profileUser.startedAt, + endedAt: profileUser.endedAt + ) + + // 저장 + try preferences.saveUser(completeUser) + + return completeUser + } + + func updateProfile(user: User) async throws -> User { + let response = try await api.updateProfile(userName: user.name) + guard let dto = response.data else { + throw NetworkError.serverError(response.status, message: response.message) + } + + let updatedProfile = dto.toModel() + + // 저장된 사용자 정보 조회 + guard let storedUser = try preferences.getStoredUser() else { + throw NetworkError.serverError(401, message: "No stored user") + } + + // 업데이트된 프로필 정보와 기존 토큰 정보 병합 + let completeUser = User( + id: updatedProfile.id, + name: updatedProfile.name, + email: user.email, + role: user.role, + accessToken: storedUser.accessToken, + refreshToken: storedUser.refreshToken, + expiresIn: storedUser.expiresIn, + position: user.position, + workspace: user.workspace, + branch: user.branch, + agencyId: user.agencyId, + startedAt: user.startedAt, + endedAt: user.endedAt + ) + + // 저장 + try preferences.saveUser(completeUser) + + return completeUser + } + + func getEmployeeList(workspace: String, organizationId: Int, page: Int, size: Int) async throws -> (employees: [Employee], hasNext: Bool) { + let response = try await api.getEmployeeList(workspace: workspace, organizationId: organizationId, page: page, size: size) + guard let dto = response.data else { + throw NetworkError.serverError(response.status, message: response.message) + } + + let employees = dto.users.map { $0.toModel() } + return (employees: employees, hasNext: dto.meta.hasNext) + } + + func editEmployee(employee: Employee, workspace: String) async throws -> Employee { + let response = try await api.editEmployee(userId: employee.userId, workspace: workspace, position: employee.position.rawValue) + guard let dto = response.data else { + throw NetworkError.serverError(response.status, message: response.message) + } + + let updatedEmployee = dto.toModel() + + // 기존 직원 정보와 업데이트된 정보 병합 + let completeEmployee = Employee( + id: updatedEmployee.userId, + userId: updatedEmployee.userId, + email: employee.email, + role: employee.role, + userName: updatedEmployee.userName.isEmpty ? employee.userName : updatedEmployee.userName, + workspace: updatedEmployee.workspace.isEmpty ? employee.workspace : updatedEmployee.workspace, + organizationId: employee.organizationId, + branch: employee.branch, + position: updatedEmployee.position, + status: employee.status, + createdAt: employee.createdAt, + startedAt: employee.startedAt, + endedAt: employee.endedAt, + deletedAt: employee.deletedAt + ) + + return completeEmployee + } + + func updateEmployeeStatus(employee: Employee, workspace: String) async throws -> Employee { + let response = try await api.updateEmployeeStatus( + userId: employee.userId, + workspace: workspace, + employeeStatus: employee.status.rawValue + ) + + guard let dto = response.data else { + throw NetworkError.serverError(response.status, message: response.message) + } + + let updatedEmployee = dto.toModel(existingEmployee: employee) + return updatedEmployee + } + + func getEmployeeCount(workspace: String, organizationId: Int) async throws -> Int { + let response = try await api.getEmployeeList(workspace: workspace, organizationId: organizationId, page: 0, size: 1) + guard let dto = response.data else { + throw NetworkError.serverError(response.status, message: response.message) + } + return dto.meta.totalElements + } +} + +// 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/User/Domain/Models/Employee.swift b/SampoomManagement/Features/User/Domain/Models/Employee.swift new file mode 100644 index 0000000..ad78218 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/Models/Employee.swift @@ -0,0 +1,26 @@ +// +// Employee.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct Employee: Equatable, Identifiable { + let id: Int + let userId: Int + let email: String + let role: String + let userName: String + let workspace: String + let organizationId: Int + let branch: String + let position: UserPosition + let status: EmployeeStatus + let createdAt: String? + let startedAt: String? + let endedAt: String? + let deletedAt: String? +} + diff --git a/SampoomManagement/Features/User/Domain/Models/EmployeeStatus.swift b/SampoomManagement/Features/User/Domain/Models/EmployeeStatus.swift new file mode 100644 index 0000000..7a1c41e --- /dev/null +++ b/SampoomManagement/Features/User/Domain/Models/EmployeeStatus.swift @@ -0,0 +1,27 @@ +// +// EmployeeStatus.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum EmployeeStatus: String, CaseIterable, Codable, Equatable { + case active = "ACTIVE" + case leave = "LEAVE" + case retired = "RETIRED" + + var displayNameKo: String { + switch self { + case .active: + return StringResources.Employee.statusActive + case .leave: + return StringResources.Employee.statusLeave + case .retired: + return StringResources.Employee.statusRetired + } + } +} + + diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/User/Domain/Models/User.swift similarity index 99% rename from SampoomManagement/Features/Auth/Domain/Models/User.swift rename to SampoomManagement/Features/User/Domain/Models/User.swift index 46b5d90..f4e8c2d 100644 --- a/SampoomManagement/Features/Auth/Domain/Models/User.swift +++ b/SampoomManagement/Features/User/Domain/Models/User.swift @@ -41,3 +41,4 @@ struct User: Equatable { let startedAt: String? let endedAt: String? } + diff --git a/SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift b/SampoomManagement/Features/User/Domain/Models/UserPosition.swift similarity index 99% rename from SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift rename to SampoomManagement/Features/User/Domain/Models/UserPosition.swift index 1d7b603..bc68e82 100644 --- a/SampoomManagement/Features/Auth/Domain/Models/UserPosition.swift +++ b/SampoomManagement/Features/User/Domain/Models/UserPosition.swift @@ -35,4 +35,3 @@ enum UserPosition: String, CaseIterable, Codable, Equatable, Hashable { } } - diff --git a/SampoomManagement/Features/User/Domain/Repository/UserRepository.swift b/SampoomManagement/Features/User/Domain/Repository/UserRepository.swift new file mode 100644 index 0000000..8ee0511 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/Repository/UserRepository.swift @@ -0,0 +1,19 @@ +// +// UserRepository.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +protocol UserRepository { + func getStoredUser() throws -> User? + func getProfile(workspace: String) async throws -> User + func updateProfile(user: User) async throws -> User + func getEmployeeList(workspace: String, organizationId: Int, page: Int, size: Int) async throws -> (employees: [Employee], hasNext: Bool) + func editEmployee(employee: Employee, workspace: String) async throws -> Employee + func updateEmployeeStatus(employee: Employee, workspace: String) async throws -> Employee + func getEmployeeCount(workspace: String, organizationId: Int) async throws -> Int +} + diff --git a/SampoomManagement/Features/User/Domain/UseCase/EditEmployeeUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/EditEmployeeUseCase.swift new file mode 100644 index 0000000..85815fe --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/EditEmployeeUseCase.swift @@ -0,0 +1,21 @@ +// +// EditEmployeeUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class EditEmployeeUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(employee: Employee, workspace: String) async throws -> Employee { + return try await repository.editEmployee(employee: employee, workspace: workspace) + } +} + diff --git a/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeCountUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeCountUseCase.swift new file mode 100644 index 0000000..aca2a60 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeCountUseCase.swift @@ -0,0 +1,22 @@ +// +// GetEmployeeCountUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class GetEmployeeCountUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(workspace: String, organizationId: Int) async throws -> Int { + return try await repository.getEmployeeCount(workspace: workspace, organizationId: organizationId) + } +} + + diff --git a/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeUseCase.swift new file mode 100644 index 0000000..b52cec1 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/GetEmployeeUseCase.swift @@ -0,0 +1,21 @@ +// +// GetEmployeeUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class GetEmployeeUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(workspace: String, organizationId: Int, page: Int, size: Int) async throws -> (employees: [Employee], hasNext: Bool) { + return try await repository.getEmployeeList(workspace: workspace, organizationId: organizationId, page: page, size: size) + } +} + diff --git a/SampoomManagement/Features/User/Domain/UseCase/GetProfileUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/GetProfileUseCase.swift new file mode 100644 index 0000000..916133f --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/GetProfileUseCase.swift @@ -0,0 +1,21 @@ +// +// GetProfileUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class GetProfileUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(workspace: String) async throws -> User { + return try await repository.getProfile(workspace: workspace) + } +} + diff --git a/SampoomManagement/Features/User/Domain/UseCase/GetStoredUserUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/GetStoredUserUseCase.swift new file mode 100644 index 0000000..f868c3c --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/GetStoredUserUseCase.swift @@ -0,0 +1,21 @@ +// +// GetStoredUserUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class GetStoredUserUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute() throws -> User? { + return try repository.getStoredUser() + } +} + diff --git a/SampoomManagement/Features/User/Domain/UseCase/UpdateEmployeeStatusUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/UpdateEmployeeStatusUseCase.swift new file mode 100644 index 0000000..d41ebe8 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/UpdateEmployeeStatusUseCase.swift @@ -0,0 +1,22 @@ +// +// UpdateEmployeeStatusUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class UpdateEmployeeStatusUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(employee: Employee, workspace: String) async throws -> Employee { + return try await repository.updateEmployeeStatus(employee: employee, workspace: workspace) + } +} + + diff --git a/SampoomManagement/Features/User/Domain/UseCase/UpdateProfileUseCase.swift b/SampoomManagement/Features/User/Domain/UseCase/UpdateProfileUseCase.swift new file mode 100644 index 0000000..9dbe022 --- /dev/null +++ b/SampoomManagement/Features/User/Domain/UseCase/UpdateProfileUseCase.swift @@ -0,0 +1,21 @@ +// +// UpdateProfileUseCase.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +class UpdateProfileUseCase { + private let repository: UserRepository + + init(repository: UserRepository) { + self.repository = repository + } + + func execute(user: User) async throws -> User { + return try await repository.updateProfile(user: user) + } +} + diff --git a/SampoomManagement/Features/User/UI/EditEmployeeBottomSheet.swift b/SampoomManagement/Features/User/UI/EditEmployeeBottomSheet.swift new file mode 100644 index 0000000..7ecae17 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EditEmployeeBottomSheet.swift @@ -0,0 +1,101 @@ +// +// EditEmployeeBottomSheet.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct EditEmployeeBottomSheet: View { + let employee: Employee + @ObservedObject var viewModel: EditEmployeeViewModel + let onDismiss: () -> Void + let onEmployeeUpdated: () -> Void + @State private var selectedPosition: UserPosition + + init( + employee: Employee, + viewModel: EditEmployeeViewModel, + onDismiss: @escaping () -> Void, + onEmployeeUpdated: @escaping () -> Void = {} + ) { + self.employee = employee + self.viewModel = viewModel + self.onDismiss = onDismiss + self.onEmployeeUpdated = onEmployeeUpdated + _selectedPosition = State(initialValue: employee.position) + } + + var body: some View { + NavigationView { + VStack(spacing: 16) { + Spacer() + // 직급 선택 + Text(StringResources.Employee.positionLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + + Menu { + ForEach(UserPosition.allCases, id: \.self) { position in + Button(action: { + selectedPosition = position + }) { + Text(position.displayNameKo) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + } + } label: { + HStack { + Text(selectedPosition.displayNameKo) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .padding(.horizontal, 16) + + Spacer() + + CommonButton( + StringResources.Common.confirm, + type: .filled, + isEnabled: !viewModel.uiState.isLoading && selectedPosition != employee.position, + action: { + viewModel.onEvent(.editEmployee(selectedPosition)) + } + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + } + .onAppear { + viewModel.onEvent(.initialize(employee)) + selectedPosition = employee.position + } + .onChange(of: viewModel.uiState.isSuccess) { _, isSuccess in + if isSuccess { + viewModel.clearStatus() + onEmployeeUpdated() + onDismiss() + } + } + } +} + diff --git a/SampoomManagement/Features/User/UI/EditEmployeeUiEvent.swift b/SampoomManagement/Features/User/UI/EditEmployeeUiEvent.swift new file mode 100644 index 0000000..1a587a1 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EditEmployeeUiEvent.swift @@ -0,0 +1,15 @@ +// +// EditEmployeeUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum EditEmployeeUiEvent { + case initialize(Employee) + case editEmployee(UserPosition) + case dismiss +} + diff --git a/SampoomManagement/Features/User/UI/EditEmployeeUiState.swift b/SampoomManagement/Features/User/UI/EditEmployeeUiState.swift new file mode 100644 index 0000000..3bfc982 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EditEmployeeUiState.swift @@ -0,0 +1,42 @@ +// +// EditEmployeeUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EditEmployeeUiState { + let employee: Employee? + let isLoading: Bool + let error: String? + let isSuccess: Bool + + init( + employee: Employee? = nil, + isLoading: Bool = false, + error: String? = nil, + isSuccess: Bool = false + ) { + self.employee = employee + self.isLoading = isLoading + self.error = error + self.isSuccess = isSuccess + } + + func copy( + employee: Employee?? = nil, + isLoading: Bool? = nil, + error: String?? = nil, + isSuccess: Bool? = nil + ) -> EditEmployeeUiState { + return EditEmployeeUiState( + employee: employee ?? self.employee, + isLoading: isLoading ?? self.isLoading, + error: error ?? self.error, + isSuccess: isSuccess ?? self.isSuccess + ) + } +} + diff --git a/SampoomManagement/Features/User/UI/EditEmployeeViewModel.swift b/SampoomManagement/Features/User/UI/EditEmployeeViewModel.swift new file mode 100644 index 0000000..044a2f0 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EditEmployeeViewModel.swift @@ -0,0 +1,96 @@ +// +// EditEmployeeViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class EditEmployeeViewModel: ObservableObject { + @Published var uiState = EditEmployeeUiState() + + private let editEmployeeUseCase: EditEmployeeUseCase + private let messageHandler: GlobalMessageHandler + + init( + editEmployeeUseCase: EditEmployeeUseCase, + messageHandler: GlobalMessageHandler + ) { + self.editEmployeeUseCase = editEmployeeUseCase + self.messageHandler = messageHandler + } + + func onEvent(_ event: EditEmployeeUiEvent) { + switch event { + case .initialize(let employee): + uiState = uiState.copy( + employee: employee, + isLoading: false, + isSuccess: false + ) + case .editEmployee(let position): + editEmployee(position: position) + case .dismiss: + uiState = uiState.copy( + employee: nil, + isLoading: false, + error: nil + ) + } + } + + private func editEmployee(position: UserPosition) { + guard let currentEmployee = uiState.employee else { + messageHandler.showMessage(StringResources.Employee.employeeNotFound, isError: true) + return + } + + Task { + uiState = uiState.copy(isLoading: true, error: nil) + + let updatedEmployee = Employee( + id: currentEmployee.userId, + userId: currentEmployee.userId, + email: currentEmployee.email, + role: currentEmployee.role, + userName: currentEmployee.userName, + workspace: currentEmployee.workspace, + organizationId: currentEmployee.organizationId, + branch: currentEmployee.branch, + position: position, + status: currentEmployee.status, + createdAt: currentEmployee.createdAt, + startedAt: currentEmployee.startedAt, + endedAt: currentEmployee.endedAt, + deletedAt: currentEmployee.deletedAt + ) + + do { + let result = try await editEmployeeUseCase.execute(employee: updatedEmployee, workspace: "AGENCY") + uiState = uiState.copy( + employee: result, + isLoading: false, + error: nil, + isSuccess: true + ) + messageHandler.showMessage(StringResources.Employee.editEdited, isError: false) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + messageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy( + isLoading: false, + error: errorMessage + ) + } + } + } + + func clearStatus() { + uiState = uiState.copy(error: nil, isSuccess: false) + } +} + diff --git a/SampoomManagement/Features/User/UI/EmployeeBottomSheetType.swift b/SampoomManagement/Features/User/UI/EmployeeBottomSheetType.swift new file mode 100644 index 0000000..4386241 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EmployeeBottomSheetType.swift @@ -0,0 +1,15 @@ +// +// EmployeeBottomSheetType.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum EmployeeBottomSheetType { + case status + case edit +} + + diff --git a/SampoomManagement/Features/User/UI/EmployeeListUiEvent.swift b/SampoomManagement/Features/User/UI/EmployeeListUiEvent.swift new file mode 100644 index 0000000..84262bd --- /dev/null +++ b/SampoomManagement/Features/User/UI/EmployeeListUiEvent.swift @@ -0,0 +1,18 @@ +// +// EmployeeListUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum EmployeeListUiEvent { + case loadEmployeeList + case retryEmployeeList + case showEditBottomSheet(Employee) + case showStatusBottomSheet(Employee) + case dismissBottomSheet + case loadMore +} + diff --git a/SampoomManagement/Features/User/UI/EmployeeListUiState.swift b/SampoomManagement/Features/User/UI/EmployeeListUiState.swift new file mode 100644 index 0000000..6e625be --- /dev/null +++ b/SampoomManagement/Features/User/UI/EmployeeListUiState.swift @@ -0,0 +1,57 @@ +// +// EmployeeListUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct EmployeeListUiState { + let employeeList: [Employee] + let employeeLoading: Bool + let employeeError: String? + let selectedEmployee: Employee? + let bottomSheetType: EmployeeBottomSheetType? + let currentPage: Int + let hasNext: Bool + + init( + employeeList: [Employee] = [], + employeeLoading: Bool = false, + employeeError: String? = nil, + selectedEmployee: Employee? = nil, + bottomSheetType: EmployeeBottomSheetType? = nil, + currentPage: Int = 0, + hasNext: Bool = true + ) { + self.employeeList = employeeList + self.employeeLoading = employeeLoading + self.employeeError = employeeError + self.selectedEmployee = selectedEmployee + self.bottomSheetType = bottomSheetType + self.currentPage = currentPage + self.hasNext = hasNext + } + + func copy( + employeeList: [Employee]? = nil, + employeeLoading: Bool? = nil, + employeeError: String?? = nil, + selectedEmployee: Employee?? = nil, + bottomSheetType: EmployeeBottomSheetType?? = nil, + currentPage: Int? = nil, + hasNext: Bool? = nil + ) -> EmployeeListUiState { + return EmployeeListUiState( + employeeList: employeeList ?? self.employeeList, + employeeLoading: employeeLoading ?? self.employeeLoading, + employeeError: employeeError ?? self.employeeError, + selectedEmployee: selectedEmployee ?? self.selectedEmployee, + bottomSheetType: bottomSheetType ?? self.bottomSheetType, + currentPage: currentPage ?? self.currentPage, + hasNext: hasNext ?? self.hasNext + ) + } +} + diff --git a/SampoomManagement/Features/User/UI/EmployeeListView.swift b/SampoomManagement/Features/User/UI/EmployeeListView.swift new file mode 100644 index 0000000..189ebb4 --- /dev/null +++ b/SampoomManagement/Features/User/UI/EmployeeListView.swift @@ -0,0 +1,261 @@ +// +// EmployeeListView.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct EmployeeListView: View { + @ObservedObject var viewModel: EmployeeListViewModel + @ObservedObject var editEmployeeViewModel: EditEmployeeViewModel + @ObservedObject var updateEmployeeStatusViewModel: UpdateEmployeeStatusViewModel + let onNavigateBack: () -> Void + @State private var showBottomSheet = false + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 16) { + if viewModel.uiState.employeeLoading && viewModel.uiState.employeeList.isEmpty { + loadingView + } else if let error = viewModel.uiState.employeeError, viewModel.uiState.employeeList.isEmpty { + errorView(error: error) + } else if viewModel.uiState.employeeList.isEmpty { + emptyView + } else { + employeeListView + } + } + .padding(16) + } + } + .navigationTitle(StringResources.Employee.title) + .navigationBarTitleDisplayMode(.large) + .background(Color.background) + .refreshable { + viewModel.onEvent(.loadEmployeeList) + } + .sheet(isPresented: $showBottomSheet) { + if let selectedEmployee = viewModel.uiState.selectedEmployee, + let bottomSheetType = viewModel.uiState.bottomSheetType { + switch bottomSheetType { + case .edit: + EditEmployeeBottomSheet( + employee: selectedEmployee, + viewModel: editEmployeeViewModel, + onDismiss: { + showBottomSheet = false + viewModel.onEvent(.dismissBottomSheet) + }, + onEmployeeUpdated: { + viewModel.onEvent(.loadEmployeeList) + } + ) + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + case .status: + UpdateEmployeeStatusBottomSheet( + employee: selectedEmployee, + viewModel: updateEmployeeStatusViewModel, + onDismiss: { + showBottomSheet = false + viewModel.onEvent(.dismissBottomSheet) + }, + onStatusUpdated: { _ in + viewModel.onEvent(.loadEmployeeList) + } + ) + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + } + } + } + .onChange(of: viewModel.uiState.selectedEmployee) { _, newValue in + showBottomSheet = newValue != nil + } + .onChange(of: viewModel.uiState.bottomSheetType) { _, newValue in + showBottomSheet = newValue != nil + } + } + + private var loadingView: some View { + VStack { + ProgressView() + .scaleEffect(1.5) + } + .frame(maxWidth: .infinity, minHeight: 200) + } + + private func errorView(error: String) -> some View { + VStack(spacing: 16) { + Text(error) + .font(.gmarketBody) + .foregroundColor(.red) + Button(StringResources.Common.retry) { + viewModel.onEvent(.retryEmployeeList) + } + } + .frame(maxWidth: .infinity, minHeight: 200) + } + + private var emptyView: some View { + VStack { + EmptyView(title: StringResources.Employee.emptyEmployee) + } + .frame(maxWidth: .infinity, minHeight: 200) + } + + private var employeeListView: some View { + LazyVStack(spacing: 16) { + ForEach(viewModel.uiState.employeeList, id: \.userId) { employee in + EmployeeListItemCard( + employee: employee, + onStatusClick: { + viewModel.onEvent(.showStatusBottomSheet(employee)) + showBottomSheet = true + }, + onEditClick: { + viewModel.onEvent(.showEditBottomSheet(employee)) + showBottomSheet = true + } + ) + } + + if viewModel.uiState.hasNext && !viewModel.uiState.employeeLoading { + Button(StringResources.Common.loadMore) { + viewModel.onEvent(.loadMore) + } + .padding() + } + + if viewModel.uiState.employeeLoading { + ProgressView() + .padding() + } + } + } +} + +struct EmployeeListItemCard: View { + let employee: Employee + let onStatusClick: () -> Void + let onEditClick: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(employee.userName) + .font(.gmarketTitle3) + .foregroundColor(.text) + Spacer() + Text(employeeStatusToKorean(employee.status)) + .font(.gmarketBody) + .foregroundColor(.text) + } + + Text(employee.position.displayNameKo) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + + HStack { + Text(StringResources.Employee.email) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + Spacer() + Text(employee.email) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + HStack { + Text(StringResources.Employee.createdAt) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + Spacer() + Text(createdAtText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + HStack { + Text(StringResources.Employee.startedAt) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + Spacer() + Text(startedAtText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + HStack { + Text(StringResources.Employee.endedAt) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + Spacer() + Text(endedAtText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + HStack { + Text(StringResources.Employee.deletedAt) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + Spacer() + Text(deletedAtText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + + HStack(spacing: 8) { + CommonButton( + StringResources.Employee.statusEdit, + type: .outlined, + action: onStatusClick + ) + + CommonButton( + StringResources.Employee.positionEdit, + type: .filled, + action: onEditClick + ) + } + } + .padding(16) + .background(Color.backgroundCard) + .cornerRadius(12) + } +} + +private extension EmployeeListItemCard { + var createdAtText: String { + guard let createdAt = employee.createdAt, !createdAt.isEmpty else { + return StringResources.Common.slash + } + return DateFormatterUtil.formatDate(createdAt) + } + + var startedAtText: String { + guard let startedAt = employee.startedAt, !startedAt.isEmpty else { + return StringResources.Common.slash + } + return DateFormatterUtil.formatDate(startedAt) + } + + var endedAtText: String { + guard let endedAt = employee.endedAt, !endedAt.isEmpty else { + return StringResources.Common.slash + } + return DateFormatterUtil.formatDate(endedAt) + } + + var deletedAtText: String { + guard let deletedAt = employee.deletedAt, !deletedAt.isEmpty else { + return StringResources.Common.slash + } + return DateFormatterUtil.formatDate(deletedAt) + } +} + diff --git a/SampoomManagement/Features/User/UI/EmployeeListViewModel.swift b/SampoomManagement/Features/User/UI/EmployeeListViewModel.swift new file mode 100644 index 0000000..5a64bbe --- /dev/null +++ b/SampoomManagement/Features/User/UI/EmployeeListViewModel.swift @@ -0,0 +1,128 @@ +// +// EmployeeListViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class EmployeeListViewModel: ObservableObject { + @Published var uiState = EmployeeListUiState() + + private let getEmployeeUseCase: GetEmployeeUseCase + private let messageHandler: GlobalMessageHandler + private let authPreferences: AuthPreferences + + init( + getEmployeeUseCase: GetEmployeeUseCase, + messageHandler: GlobalMessageHandler, + authPreferences: AuthPreferences + ) { + self.getEmployeeUseCase = getEmployeeUseCase + self.messageHandler = messageHandler + self.authPreferences = authPreferences + loadEmployeeList() + } + + func onEvent(_ event: EmployeeListUiEvent) { + switch event { + case .loadEmployeeList: + loadEmployeeList() + case .retryEmployeeList: + loadEmployeeList() + case .showEditBottomSheet(let employee): + presentBottomSheet(for: employee, type: .edit) + case .showStatusBottomSheet(let employee): + presentBottomSheet(for: employee, type: .status) + case .dismissBottomSheet: + uiState = uiState.copy(selectedEmployee: .some(nil), bottomSheetType: .some(nil)) + case .loadMore: + loadMoreEmployees() + } + } + + private func presentBottomSheet(for employee: Employee, type: EmployeeBottomSheetType) { + if uiState.selectedEmployee?.userId == employee.userId && uiState.bottomSheetType == type { + // toggle to allow re-selection of identical employee & sheet + uiState = uiState.copy(selectedEmployee: .some(nil), bottomSheetType: .some(nil)) + Task { @MainActor in + self.uiState = self.uiState.copy(selectedEmployee: .some(employee), bottomSheetType: .some(type)) + } + } else { + uiState = uiState.copy(selectedEmployee: .some(employee), bottomSheetType: .some(type)) + } + } + + private func loadEmployeeList() { + Task { + uiState = uiState.copy( + employeeList: [], + employeeLoading: true, + employeeError: nil, + selectedEmployee: .some(nil), + bottomSheetType: .some(nil), + currentPage: 0 + ) + await loadEmployees(page: 0) + } + } + + private func loadMoreEmployees() { + guard !uiState.employeeLoading && uiState.hasNext else { return } + Task { + await loadEmployees(page: uiState.currentPage + 1) + } + } + + private func loadEmployees(page: Int) async { + guard let user = try? authPreferences.getStoredUser() else { + await MainActor.run { + uiState = uiState.copy(employeeLoading: false, employeeError: StringResources.Employee.userNotFound) + } + return + } + + do { + let result = try await getEmployeeUseCase.execute( + workspace: "AGENCY", + organizationId: user.agencyId, + page: page, + size: 20 + ) + + await MainActor.run { + if page == 0 { + uiState = uiState.copy( + employeeList: result.employees, + employeeLoading: false, + employeeError: nil, + currentPage: page, + hasNext: result.hasNext + ) + } else { + uiState = uiState.copy( + employeeList: uiState.employeeList + result.employees, + employeeLoading: false, + employeeError: nil, + currentPage: page, + hasNext: result.hasNext + ) + } + } + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + messageHandler.showMessage(errorMessage, isError: true) + await MainActor.run { + uiState = uiState.copy( + employeeLoading: false, + employeeError: errorMessage + ) + } + } + } +} + diff --git a/SampoomManagement/Features/User/UI/UpdateEmployeeStatusBottomSheet.swift b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusBottomSheet.swift new file mode 100644 index 0000000..86c8741 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusBottomSheet.swift @@ -0,0 +1,105 @@ +// +// UpdateEmployeeStatusBottomSheet.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct UpdateEmployeeStatusBottomSheet: View { + let employee: Employee + @ObservedObject var viewModel: UpdateEmployeeStatusViewModel + let onDismiss: () -> Void + let onStatusUpdated: (Employee) -> Void + @State private var selectedStatus: EmployeeStatus + + init( + employee: Employee, + viewModel: UpdateEmployeeStatusViewModel, + onDismiss: @escaping () -> Void, + onStatusUpdated: @escaping (Employee) -> Void = { _ in } + ) { + self.employee = employee + self.viewModel = viewModel + self.onDismiss = onDismiss + self.onStatusUpdated = onStatusUpdated + _selectedStatus = State(initialValue: employee.status) + } + + var body: some View { + NavigationView { + VStack(spacing: 16) { + Spacer() + Text(StringResources.Employee.statusLabel) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 16) + + Menu { + ForEach(EmployeeStatus.allCases, id: \.self) { status in + Button(action: { + selectedStatus = status + }) { + Text(status.displayNameKo) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + } + } label: { + HStack { + Text(selectedStatus.displayNameKo) + .font(.gmarketBody) + .foregroundColor(Color("Text")) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) + .background( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .padding(.horizontal, 16) + + Spacer() + + CommonButton( + StringResources.Common.confirm, + type: .filled, + isEnabled: !viewModel.uiState.isLoading && selectedStatus != employee.status, + action: { + viewModel.onEvent(.editEmployeeStatus(selectedStatus)) + } + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + } + .onAppear { + viewModel.bindLabel( + error: StringResources.Common.error, + editEmployee: StringResources.Employee.editStatusEdited + ) + viewModel.onEvent(.initialize(employee)) + selectedStatus = employee.status + } + .onChange(of: viewModel.uiState.isSuccess) { _, isSuccess in + if isSuccess, let updatedEmployee = viewModel.uiState.employee { + viewModel.clearStatus() + onStatusUpdated(updatedEmployee) + onDismiss() + } + } + } +} + + diff --git a/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiEvent.swift b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiEvent.swift new file mode 100644 index 0000000..0b0dd04 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiEvent.swift @@ -0,0 +1,16 @@ +// +// UpdateEmployeeStatusUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum UpdateEmployeeStatusUiEvent { + case initialize(Employee) + case editEmployeeStatus(EmployeeStatus) + case dismiss +} + + diff --git a/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiState.swift b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiState.swift new file mode 100644 index 0000000..9489a80 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusUiState.swift @@ -0,0 +1,43 @@ +// +// UpdateEmployeeStatusUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateEmployeeStatusUiState { + let employee: Employee? + let isLoading: Bool + let error: String? + let isSuccess: Bool + + init( + employee: Employee? = nil, + isLoading: Bool = false, + error: String? = nil, + isSuccess: Bool = false + ) { + self.employee = employee + self.isLoading = isLoading + self.error = error + self.isSuccess = isSuccess + } + + func copy( + employee: Employee?? = nil, + isLoading: Bool? = nil, + error: String?? = nil, + isSuccess: Bool? = nil + ) -> UpdateEmployeeStatusUiState { + return UpdateEmployeeStatusUiState( + employee: employee ?? self.employee, + isLoading: isLoading ?? self.isLoading, + error: error ?? self.error, + isSuccess: isSuccess ?? self.isSuccess + ) + } +} + + diff --git a/SampoomManagement/Features/User/UI/UpdateEmployeeStatusViewModel.swift b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusViewModel.swift new file mode 100644 index 0000000..c80df1d --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateEmployeeStatusViewModel.swift @@ -0,0 +1,104 @@ +// +// UpdateEmployeeStatusViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import Combine + +@MainActor +class UpdateEmployeeStatusViewModel: ObservableObject { + @Published var uiState = UpdateEmployeeStatusUiState() + + private let updateEmployeeStatusUseCase: UpdateEmployeeStatusUseCase + private let messageHandler: GlobalMessageHandler + + init( + updateEmployeeStatusUseCase: UpdateEmployeeStatusUseCase, + messageHandler: GlobalMessageHandler + ) { + self.updateEmployeeStatusUseCase = updateEmployeeStatusUseCase + self.messageHandler = messageHandler + } + + private var errorLabel: String = "" + private var editEmployeeLabel: String = "" + + func onEvent(_ event: UpdateEmployeeStatusUiEvent) { + switch event { + case .initialize(let employee): + uiState = uiState.copy( + employee: employee, + isLoading: false, + isSuccess: false + ) + case .editEmployeeStatus(let status): + updateEmployeeStatus(status: status) + case .dismiss: + uiState = uiState.copy( + employee: nil, + isLoading: false, + error: nil + ) + } + } + + func bindLabel(error: String, editEmployee: String) { + errorLabel = error + editEmployeeLabel = editEmployee + } + + private func updateEmployeeStatus(status: EmployeeStatus) { + guard let currentEmployee = uiState.employee else { + messageHandler.showMessage(errorLabel, isError: true) + return + } + + Task { + uiState = uiState.copy(isLoading: true, error: nil) + + let updatedEmployee = Employee( + id: currentEmployee.id, + userId: currentEmployee.userId, + email: currentEmployee.email, + role: currentEmployee.role, + userName: currentEmployee.userName, + workspace: currentEmployee.workspace, + organizationId: currentEmployee.organizationId, + branch: currentEmployee.branch, + position: currentEmployee.position, + status: status, + createdAt: currentEmployee.createdAt, + startedAt: currentEmployee.startedAt, + endedAt: currentEmployee.endedAt, + deletedAt: currentEmployee.deletedAt + ) + + do { + let result = try await updateEmployeeStatusUseCase.execute(employee: updatedEmployee, workspace: "AGENCY") + uiState = uiState.copy( + employee: result, + isLoading: false, + error: nil, + isSuccess: true + ) + messageHandler.showMessage(editEmployeeLabel, isError: false) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + messageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy( + isLoading: false, + error: errorMessage + ) + } + } + } + + func clearStatus() { + uiState = uiState.copy(error: nil, isSuccess: false) + } +} + + diff --git a/SampoomManagement/Features/User/UI/UpdateProfileBottomSheet.swift b/SampoomManagement/Features/User/UI/UpdateProfileBottomSheet.swift new file mode 100644 index 0000000..d2c3975 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateProfileBottomSheet.swift @@ -0,0 +1,71 @@ +// +// UpdateProfileBottomSheet.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct UpdateProfileBottomSheet: View { + let user: User + @ObservedObject var viewModel: UpdateProfileViewModel + let onProfileUpdated: (User) -> Void + let onDismiss: () -> Void + @State private var userName: String + + init( + user: User, + viewModel: UpdateProfileViewModel, + onProfileUpdated: @escaping (User) -> Void = { _ in }, + onDismiss: @escaping () -> Void + ) { + self.user = user + self.viewModel = viewModel + self.onProfileUpdated = onProfileUpdated + self.onDismiss = onDismiss + _userName = State(initialValue: user.name) + } + + var body: some View { + NavigationView { + VStack(spacing: 16) { + CommonTextField( + value: $userName, + placeholder: StringResources.Setting.editProfilePlaceholderUsername, + type: .text + ) + .padding(.horizontal, 16) + + Spacer() + + CommonButton( + StringResources.Setting.editProfile, + type: .filled, + isEnabled: !viewModel.uiState.isLoading && !userName.isEmpty && userName != user.name, + action: { + viewModel.onEvent(.updateProfile(userName)) + } + ) + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + .navigationTitle(StringResources.Setting.editProfile) + .background(Color.background) + } + .onAppear { + viewModel.onEvent(.initialize(user)) + userName = user.name + } + .onChange(of: viewModel.uiState.isSuccess) { _, isSuccess in + if isSuccess { + if let updatedUser = viewModel.uiState.user { + onProfileUpdated(updatedUser) + } + viewModel.clearStatus() + onDismiss() + } + } + } +} + diff --git a/SampoomManagement/Features/User/UI/UpdateProfileUiEvent.swift b/SampoomManagement/Features/User/UI/UpdateProfileUiEvent.swift new file mode 100644 index 0000000..2aca40a --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateProfileUiEvent.swift @@ -0,0 +1,15 @@ +// +// UpdateProfileUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum UpdateProfileUiEvent { + case initialize(User) + case updateProfile(String) + case dismiss +} + diff --git a/SampoomManagement/Features/User/UI/UpdateProfileUiState.swift b/SampoomManagement/Features/User/UI/UpdateProfileUiState.swift new file mode 100644 index 0000000..8b9a6f1 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateProfileUiState.swift @@ -0,0 +1,42 @@ +// +// UpdateProfileUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct UpdateProfileUiState { + let user: User? + let isLoading: Bool + let error: String? + let isSuccess: Bool + + init( + user: User? = nil, + isLoading: Bool = false, + error: String? = nil, + isSuccess: Bool = false + ) { + self.user = user + self.isLoading = isLoading + self.error = error + self.isSuccess = isSuccess + } + + func copy( + user: User? = nil, + isLoading: Bool? = nil, + error: String? = nil, + isSuccess: Bool? = nil + ) -> UpdateProfileUiState { + return UpdateProfileUiState( + user: user ?? self.user, + isLoading: isLoading ?? self.isLoading, + error: error ?? self.error, + isSuccess: isSuccess ?? self.isSuccess + ) + } +} + diff --git a/SampoomManagement/Features/User/UI/UpdateProfileViewModel.swift b/SampoomManagement/Features/User/UI/UpdateProfileViewModel.swift new file mode 100644 index 0000000..040bae0 --- /dev/null +++ b/SampoomManagement/Features/User/UI/UpdateProfileViewModel.swift @@ -0,0 +1,95 @@ +// +// UpdateProfileViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class UpdateProfileViewModel: ObservableObject { + @Published var uiState = UpdateProfileUiState() + + private let updateProfileUseCase: UpdateProfileUseCase + private let messageHandler: GlobalMessageHandler + + init( + updateProfileUseCase: UpdateProfileUseCase, + messageHandler: GlobalMessageHandler + ) { + self.updateProfileUseCase = updateProfileUseCase + self.messageHandler = messageHandler + } + + func onEvent(_ event: UpdateProfileUiEvent) { + switch event { + case .initialize(let user): + uiState = uiState.copy( + user: user, + isLoading: false, + isSuccess: false + ) + case .updateProfile(let userName): + updateProfile(userName: userName) + case .dismiss: + uiState = uiState.copy( + user: nil, + isLoading: false, + error: nil + ) + } + } + + private func updateProfile(userName: String) { + guard let currentUser = uiState.user else { + messageHandler.showMessage(StringResources.Setting.userNotFound, isError: true) + return + } + + Task { + uiState = uiState.copy(isLoading: true, error: nil) + + let updatedUser = User( + id: currentUser.id, + name: userName, + email: currentUser.email, + role: currentUser.role, + accessToken: currentUser.accessToken, + refreshToken: currentUser.refreshToken, + expiresIn: currentUser.expiresIn, + position: currentUser.position, + workspace: currentUser.workspace, + branch: currentUser.branch, + agencyId: currentUser.agencyId, + startedAt: currentUser.startedAt, + endedAt: currentUser.endedAt + ) + + do { + let result = try await updateProfileUseCase.execute(user: updatedUser) + uiState = uiState.copy( + user: result, + isLoading: false, + error: nil, + isSuccess: true + ) + messageHandler.showMessage(StringResources.Setting.editProfileEdited, isError: false) + } catch { + let errorMessage = (error as? NetworkError)?.errorDescription ?? error.localizedDescription + messageHandler.showMessage(errorMessage, isError: true) + uiState = uiState.copy( + isLoading: false, + error: errorMessage + ) + } + } + } + + func clearStatus() { + uiState = uiState.copy(error: nil, isSuccess: false) + } +} +