From 9b46102dff0497a863a1912045a98602b11e6cfe Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Wed, 29 Oct 2025 17:10:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20UI=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 20 ++- .../App/Screens/DashboardScreen.swift | 43 ----- .../Core/Network/TokenRefreshService.swift | 5 +- .../Core/UI/Components/CommonTextField.swift | 12 +- .../Core/UI/Components/OrderItem.swift | 41 +++++ .../Local/Preferences/AuthPreferences.swift | 21 ++- .../Auth/Data/Mappers/AuthMappers.swift | 39 ++++- .../Auth/Data/Remote/API/AuthAPI.swift | 10 ++ .../Remote/DTO/GetProfileResponseDTO.swift | 18 ++ .../Data/Remote/DTO/LoginResponseDTO.swift | 2 +- .../Data/Repository/AuthRepositoryImpl.swift | 39 ++++- .../Features/Auth/Domain/Models/User.swift | 4 + .../Features/Auth/UI/SignUpView.swift | 65 ++++--- .../Dashboard/UI/DashboardUiEvent.swift | 15 ++ .../Dashboard/UI/DashboardUiState.swift | 38 ++++ .../Features/Dashboard/UI/DashboardView.swift | 164 ++++++++++++++++++ .../Dashboard/UI/DashboardViewModel.swift | 50 ++++++ .../money.imageset/Contents.json | 12 ++ .../Assets.xcassets/money.imageset/money.svg | 10 ++ .../warning.imageset/Contents.json | 12 ++ .../warning.imageset/warning.svg | 3 + 21 files changed, 539 insertions(+), 84 deletions(-) delete mode 100644 SampoomManagement/App/Screens/DashboardScreen.swift create mode 100644 SampoomManagement/Core/UI/Components/OrderItem.swift create mode 100644 SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift create mode 100644 SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift create mode 100644 SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift create mode 100644 SampoomManagement/Features/Dashboard/UI/DashboardView.swift create mode 100644 SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift create mode 100644 SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json create mode 100644 SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg create mode 100644 SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json create mode 100644 SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index a848274..8983ceb 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -33,9 +33,25 @@ struct ContentView: View { .ignoresSafeArea(.all) TabView(selection: $selectedTab) { - // Dashboard 탭 (임시) + // Dashboard 탭 (DashboardView directly) Tab(value: .dashboard) { - DashboardScreen(dependencies: dependencies) + NavigationStack { + DashboardView( + viewModel: DashboardViewModel(getOrderUseCase: dependencies.getOrderUseCase), + onLogoutClick: { + Task { await dependencies.authViewModel.signOut() } + }, + onNavigateOrderDetail: { order in + selectedTab = .orders + DispatchQueue.main.async { + ordersNavigationPath.append(order.orderId) + } + }, + onNavigateOrderList: { + selectedTab = .orders + } + ) + } } label: { Label { Text(StringResources.Tabs.dashboard) diff --git a/SampoomManagement/App/Screens/DashboardScreen.swift b/SampoomManagement/App/Screens/DashboardScreen.swift deleted file mode 100644 index f768124..0000000 --- a/SampoomManagement/App/Screens/DashboardScreen.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// DashboardScreen.swift -// SampoomManagement -// -// Created by 채상윤 on 10/25/25. -// - -import SwiftUI - -struct DashboardScreen: View { - let dependencies: AppDependencies - - var body: some View { - NavigationStack { - VStack(spacing: 20) { - Spacer() - Text(StringResources.Tabs.dashboard) - .font(.largeTitle) - .fontWeight(.bold) - - Text(StringResources.Placeholders.inventoryDescription) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - // 로그아웃 버튼 - Spacer() - CommonButton(StringResources.Auth.logoutButton, backgroundColor: .red, textColor: .white) { - Task { - await dependencies.authViewModel.signOut() - } - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - - Spacer() - } - .navigationTitle(StringResources.Tabs.dashboard) - .background(Color.background) - } - } -} diff --git a/SampoomManagement/Core/Network/TokenRefreshService.swift b/SampoomManagement/Core/Network/TokenRefreshService.swift index 31c7462..583612c 100644 --- a/SampoomManagement/Core/Network/TokenRefreshService.swift +++ b/SampoomManagement/Core/Network/TokenRefreshService.swift @@ -54,7 +54,10 @@ class TokenRefreshService { role: existingUser.role, accessToken: dto.accessToken, refreshToken: dto.refreshToken, - expiresIn: dto.expiresIn + expiresIn: dto.expiresIn, + position: existingUser.position, + workspace: existingUser.workspace, + branch: existingUser.branch ) try authPreferences.saveUser(updatedUser) diff --git a/SampoomManagement/Core/UI/Components/CommonTextField.swift b/SampoomManagement/Core/UI/Components/CommonTextField.swift index 0b609c9..3ac4c36 100644 --- a/SampoomManagement/Core/UI/Components/CommonTextField.swift +++ b/SampoomManagement/Core/UI/Components/CommonTextField.swift @@ -59,6 +59,8 @@ struct CommonTextField: View { let isError: Bool let errorMessage: String? let onTextChange: (String) -> Void + let submitLabel: SubmitLabel + let onSubmit: () -> Void init( value: Binding, @@ -67,7 +69,9 @@ struct CommonTextField: View { size: TextFieldSize = .medium, isError: Bool = false, errorMessage: String? = nil, - onTextChange: @escaping (String) -> Void = { _ in } + onTextChange: @escaping (String) -> Void = { _ in }, + submitLabel: SubmitLabel = .next, + onSubmit: @escaping () -> Void = {} ) { self._value = value self.placeholder = placeholder @@ -76,6 +80,8 @@ struct CommonTextField: View { self.isError = isError self.errorMessage = errorMessage self.onTextChange = onTextChange + self.submitLabel = submitLabel + self.onSubmit = onSubmit } var body: some View { @@ -85,6 +91,8 @@ struct CommonTextField: View { Group { if type == .password && !isPasswordVisible { SecureField(placeholder, text: $value) + .submitLabel(submitLabel) + .onSubmit { onSubmit() } .textFieldStyle(PlainTextFieldStyle()) .focused($isFocused) } else { @@ -92,6 +100,8 @@ struct CommonTextField: View { .keyboardType(keyboardType) .textInputAutocapitalization(autocapitalization) .disableAutocorrection(disableAutocorrection) + .submitLabel(submitLabel) + .onSubmit { onSubmit() } .textFieldStyle(PlainTextFieldStyle()) .focused($isFocused) } diff --git a/SampoomManagement/Core/UI/Components/OrderItem.swift b/SampoomManagement/Core/UI/Components/OrderItem.swift new file mode 100644 index 0000000..5896eee --- /dev/null +++ b/SampoomManagement/Core/UI/Components/OrderItem.swift @@ -0,0 +1,41 @@ +// +// OrderItem.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct OrderItem: View { + let order: Order + let onClick: () -> Void + + var body: some View { + Button(action: onClick) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(OrderFormatter.buildOrderTitle(order)) + .font(.gmarketBody) + .lineLimit(1) + .foregroundColor(.text) + Text(order.agencyName ?? "-") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + Spacer(minLength: 12) + VStack(alignment: .trailing, spacing: 6) { + Text(order.createdAt ?? "-") + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + StatusChip(status: order.status.rawValue) + } + } + .padding(16) + .background(Color.backgroundCard) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} + + diff --git a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift index dd73461..d0b656b 100644 --- a/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift +++ b/SampoomManagement/Features/Auth/Data/Local/Preferences/AuthPreferences.swift @@ -17,6 +17,9 @@ class AuthPreferences { static let userName = "auth.userName" static let userRole = "auth.userRole" static let expiresIn = "auth.expiresIn" + static let position = "auth.position" + static let workspace = "auth.workspace" + static let branch = "auth.branch" } func saveUser(_ user: User) throws { @@ -27,6 +30,9 @@ class AuthPreferences { try keychain.save(user.name, for: Keys.userName) try keychain.save(user.role, for: Keys.userRole) try keychain.save(String(user.expiresIn), for: Keys.expiresIn) + try keychain.save(user.position, for: Keys.position) + try keychain.save(user.workspace, for: Keys.workspace) + try keychain.save(user.branch, for: Keys.branch) } catch { // 부분 저장 실패 시 롤백 try? keychain.delete(Keys.accessToken) @@ -35,6 +41,9 @@ class AuthPreferences { try? keychain.delete(Keys.userName) try? keychain.delete(Keys.userRole) try? keychain.delete(Keys.expiresIn) + try? keychain.delete(Keys.position) + try? keychain.delete(Keys.workspace) + try? keychain.delete(Keys.branch) throw error } } @@ -63,6 +72,10 @@ class AuthPreferences { let expiresIn = Int(expiresInString) else { return nil } + // Tolerate missing profile keys by defaulting to empty strings + let position = (try? keychain.get(Keys.position)) ?? "" + let workspace = (try? keychain.get(Keys.workspace)) ?? "" + let branch = (try? keychain.get(Keys.branch)) ?? "" return User( id: userId, @@ -70,7 +83,10 @@ class AuthPreferences { role: userRole, accessToken: accessToken, refreshToken: refreshToken, - expiresIn: expiresIn + expiresIn: expiresIn, + position: position, + workspace: workspace, + branch: branch ) } catch { print("AuthPreferences - 사용자 정보 조회 실패: \(error)") @@ -113,6 +129,9 @@ class AuthPreferences { try keychain.delete(Keys.userName) try keychain.delete(Keys.userRole) try keychain.delete(Keys.expiresIn) + try keychain.delete(Keys.position) + try keychain.delete(Keys.workspace) + try keychain.delete(Keys.branch) } catch { // 로그아웃 시에는 실패해도 에러를 던지지 않음 (이미 로그아웃 상태로 간주) print("AuthPreferences - 키체인 삭제 실패: \(error)") diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift index b7887ed..95d0c6a 100644 --- a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -11,11 +11,46 @@ extension LoginResponseDTO { func toModel() -> User { return User( id: self.userId, - name: self.userName, + name: self.userName ?? "", role: self.role, accessToken: self.accessToken, refreshToken: self.refreshToken, - expiresIn: self.expiresIn + expiresIn: self.expiresIn, + position: "", + workspace: "", + branch: "" + ) + } +} + +extension GetProfileResponseDTO { + func toModel() -> User { + return User( + id: self.userId, + name: self.userName ?? "", + role: "", + accessToken: "", + refreshToken: "", + expiresIn: 0, + position: self.position ?? "", + workspace: self.workspace ?? "", + branch: self.branch ?? "" + ) + } +} + +extension User { + func mergeWith(profile: User) -> User { + return User( + id: self.id, + name: profile.name, + role: self.role, + accessToken: self.accessToken, + refreshToken: self.refreshToken, + expiresIn: self.expiresIn, + position: profile.position, + workspace: profile.workspace, + branch: profile.branch ) } } diff --git a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift index bf37626..eb5e434 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift +++ b/SampoomManagement/Features/Auth/Data/Remote/API/AuthAPI.swift @@ -92,5 +92,15 @@ class AuthAPI { responseType: RefreshResponseDTO.self ) } + + // 프로필 조회 + func getProfile() async throws -> APIResponse { + return try await networkManager.request( + endpoint: "user/profile", + method: .get, + parameters: nil, + responseType: GetProfileResponseDTO.self + ) + } } diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift new file mode 100644 index 0000000..2deb3b8 --- /dev/null +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/GetProfileResponseDTO.swift @@ -0,0 +1,18 @@ +// +// GetProfileResponseDTO.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct GetProfileResponseDTO: Codable { + let userId: Int + let userName: String? + let workspace: String? + let branch: String? + let position: String? +} + + diff --git a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift index dc04079..cf55bbf 100644 --- a/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift +++ b/SampoomManagement/Features/Auth/Data/Remote/DTO/LoginResponseDTO.swift @@ -9,7 +9,7 @@ import Foundation struct LoginResponseDTO: Codable { let userId: Int - let userName: String + let userName: String? let role: String let accessToken: String let refreshToken: String diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift index 8757a37..0a87456 100644 --- a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -39,21 +39,39 @@ class AuthRepositoryImpl: AuthRepository { } func signIn(email: String, password: String) async throws -> User { - let response = try await api.login(email: email, password: password) - guard let dto = response.data else { + // 1) 로그인 + let loginResponse = try await api.login(email: email, password: password) + guard let loginDto = loginResponse.data else { throw AuthError.invalidResponse } - - let user = dto.toModel() + let loginUser = loginDto.toModel() + // Store tokens immediately so that subsequent authorized calls (e.g., getProfile) carry Authorization header do { - try preferences.saveUser(user) + try preferences.saveToken(accessToken: loginUser.accessToken, refreshToken: loginUser.refreshToken) + } catch { + print("AuthRepositoryImpl - 초기 토큰 저장 실패: \(error)") + throw AuthError.tokenSaveFailed(error) + } + + // 2) 프로필 조회 + let profileResponse = try await api.getProfile() + guard let profileDto = profileResponse.data else { + throw AuthError.invalidResponse + } + let profileUser = profileDto.toModel() + + // 3) 병합 + let mergedUser = loginUser.mergeWith(profile: profileUser) + + // 4) 저장 + do { + try preferences.saveUser(mergedUser) } catch { - // 키체인 저장 실패 시 로깅 및 에러 전파 print("AuthRepositoryImpl - 키체인 저장 실패: \(error)") throw AuthError.tokenSaveFailed(error) } - - return user + + return mergedUser } func signOut() async throws { @@ -90,7 +108,10 @@ class AuthRepositoryImpl: AuthRepository { role: existingUser.role, accessToken: dto.accessToken, refreshToken: dto.refreshToken, - expiresIn: dto.expiresIn + expiresIn: dto.expiresIn, + position: existingUser.position, + workspace: existingUser.workspace, + branch: existingUser.branch ) do { diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/Auth/Domain/Models/User.swift index a3e9f82..7b3ea01 100644 --- a/SampoomManagement/Features/Auth/Domain/Models/User.swift +++ b/SampoomManagement/Features/Auth/Domain/Models/User.swift @@ -14,4 +14,8 @@ struct User: Equatable { let accessToken: String let refreshToken: String let expiresIn: Int + // Additional profile fields merged after login + let position: String + let workspace: String + let branch: String } diff --git a/SampoomManagement/Features/Auth/UI/SignUpView.swift b/SampoomManagement/Features/Auth/UI/SignUpView.swift index 4f1b907..9673743 100644 --- a/SampoomManagement/Features/Auth/UI/SignUpView.swift +++ b/SampoomManagement/Features/Auth/UI/SignUpView.swift @@ -17,11 +17,16 @@ struct SignUpView: View { @State private var email = "" @State private var password = "" @State private var passwordCheck = "" + @FocusState private var focusedField: Field? let onSuccess: () -> Void private let labelTextSize: CGFloat = 16 + private enum Field: Hashable { + case name, branch, position, email, password, passwordCheck + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -44,10 +49,12 @@ struct SignUpView: View { value: $name, placeholder: StringResources.Auth.namePlaceholder, isError: viewModel.uiState.nameError != nil, - errorMessage: viewModel.uiState.nameError - ) { text in - viewModel.updateName(text) - } + errorMessage: viewModel.uiState.nameError, + onTextChange: { text in viewModel.updateName(text) }, + submitLabel: .next, + onSubmit: { focusedField = .branch } + ) + .focused($focusedField, equals: .name) Spacer() .frame(height: 8) @@ -61,10 +68,12 @@ struct SignUpView: View { value: $branch, placeholder: StringResources.Auth.branchPlaceholder, isError: viewModel.uiState.branchError != nil, - errorMessage: viewModel.uiState.branchError - ) { text in - viewModel.updateBranch(text) - } + errorMessage: viewModel.uiState.branchError, + onTextChange: { text in viewModel.updateBranch(text) }, + submitLabel: .next, + onSubmit: { focusedField = .position } + ) + .focused($focusedField, equals: .branch) Spacer() .frame(height: 8) @@ -78,10 +87,12 @@ struct SignUpView: View { value: $position, placeholder: StringResources.Auth.positionPlaceholder, isError: viewModel.uiState.positionError != nil, - errorMessage: viewModel.uiState.positionError - ) { text in - viewModel.updatePosition(text) - } + errorMessage: viewModel.uiState.positionError, + onTextChange: { text in viewModel.updatePosition(text) }, + submitLabel: .next, + onSubmit: { focusedField = .email } + ) + .focused($focusedField, equals: .position) Spacer() .frame(height: 8) @@ -96,10 +107,12 @@ struct SignUpView: View { placeholder: StringResources.Auth.emailPlaceholder, type: .email, isError: viewModel.uiState.emailError != nil, - errorMessage: viewModel.uiState.emailError - ) { text in - viewModel.updateEmail(text) - } + errorMessage: viewModel.uiState.emailError, + onTextChange: { text in viewModel.updateEmail(text) }, + submitLabel: .next, + onSubmit: { focusedField = .password } + ) + .focused($focusedField, equals: .email) Spacer() .frame(height: 8) @@ -114,10 +127,12 @@ struct SignUpView: View { placeholder: StringResources.Auth.passwordPlaceholder, type: .password, isError: viewModel.uiState.passwordError != nil, - errorMessage: viewModel.uiState.passwordError - ) { text in - viewModel.updatePassword(text) - } + errorMessage: viewModel.uiState.passwordError, + onTextChange: { text in viewModel.updatePassword(text) }, + submitLabel: .next, + onSubmit: { focusedField = .passwordCheck } + ) + .focused($focusedField, equals: .password) Spacer() .frame(height: 8) @@ -132,10 +147,12 @@ struct SignUpView: View { placeholder: StringResources.Auth.passwordCheckPlaceholder, type: .password, isError: viewModel.uiState.passwordCheckError != nil, - errorMessage: viewModel.uiState.passwordCheckError - ) { text in - viewModel.updatePasswordCheck(text) - } + errorMessage: viewModel.uiState.passwordCheckError, + onTextChange: { text in viewModel.updatePasswordCheck(text) }, + submitLabel: .done, + onSubmit: { focusedField = nil } + ) + .focused($focusedField, equals: .passwordCheck) Spacer() .frame(height: 48) diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift new file mode 100644 index 0000000..19a290c --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiEvent.swift @@ -0,0 +1,15 @@ +// +// DashboardUiEvent.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +enum DashboardUiEvent { + case loadDashboard + case retryDashboard +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift new file mode 100644 index 0000000..fdef142 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift @@ -0,0 +1,38 @@ +// +// DashboardUiState.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation + +struct DashboardUiState: Equatable { + let orderList: [Order] + let dashboardLoading: Bool + let dashboardError: String? + + init( + orderList: [Order] = [], + dashboardLoading: Bool = false, + dashboardError: String? = nil + ) { + self.orderList = orderList + self.dashboardLoading = dashboardLoading + self.dashboardError = dashboardError + } + + func copy( + orderList: [Order]? = nil, + dashboardLoading: Bool? = nil, + dashboardError: String? = nil + ) -> DashboardUiState { + return DashboardUiState( + orderList: orderList ?? self.orderList, + dashboardLoading: dashboardLoading ?? self.dashboardLoading, + dashboardError: dashboardError + ) + } +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift new file mode 100644 index 0000000..372eeb0 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -0,0 +1,164 @@ +// +// DashboardView.swift +// SampoomManagement +// +// Created by Generated. +// + +import SwiftUI + +struct DashboardView: View { + @ObservedObject var viewModel: DashboardViewModel + let onLogoutClick: () -> Void + let onNavigateOrderDetail: (Order) -> Void + let onNavigateOrderList: () -> Void + var isManager = true + + var body: some View { + VStack(spacing: 0) { + // Top bar + HStack { + Image("oneline_logo") + .resizable() + .scaledToFit() + .frame(height: 24) + Spacer() + HStack(spacing: 12) { + // TODO: role-based employee button + if (isManager) { + Button(action: {}) { + Image("employee").renderingMode(.template).foregroundStyle(.text) + } + } + Button(action: {}) { + Image("settings").renderingMode(.template).foregroundStyle(.text) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + + ScrollView { + VStack(spacing: 16) { + // Logout (temporary) + CommonButton(StringResources.Auth.logoutButton, backgroundColor: .red, textColor: .white) { + onLogoutClick() + } + + titleSection + buttonSection + orderListSection + Spacer(minLength: 100) + } + .padding(.horizontal, 16) + } + } + .background(Color.background) + .refreshable { + viewModel.onEvent(.loadDashboard) + } + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("가산디지털단지점") + .font(.gmarketTitle2) + .fontWeight(.bold) + .foregroundColor(.text) + + Group { + Text("안녕하세요, ") + Text("홍길동").foregroundColor(.accentColor) + Text(" 님") + } + .font(.gmarketTitle) + .fontWeight(.bold) + .foregroundColor(.text) + + Text("필요한 정보를 한 눈에 확인하세요.") + .font(.gmarketBody) + .foregroundColor(.text) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 24) + } + + private var buttonSection: some View { + VStack(spacing: 16) { + if (isManager) { + buttonCard(iconName: "employee", valueText: "45", subText: "직원 관리", bordered: true) {} + } + HStack(spacing: 16) { + buttonCard(iconName: "parts", valueText: "1234", subText: "보유 부품") {} + buttonCard(iconName: "orders", valueText: "23", subText: "진행중 주문") {} + } + HStack(spacing: 16) { + buttonCard(iconName: "warning", valueText: "19", subText: "부족 부품") {} + buttonCard(iconName: "money", valueText: "4,123,200", subText: "주문 금액") {} + } + } + .padding(.bottom, 16) + } + + private func buttonCard(iconName: String, valueText: String, subText: String, bordered: Bool = false, onClick: @escaping () -> Void) -> some View { + Button(action: onClick) { + VStack(alignment: .center, spacing: 16) { + Image(iconName) + .renderingMode(.template) + .foregroundStyle(Color.white) + .padding(8) + .background(Color.accentColor) + .clipShape(Circle()) + Text(valueText) + .font(.gmarketTitle2) + .foregroundColor(.text) + Text(subText) + .font(.gmarketBody) + .foregroundColor(.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(Color.backgroundCard) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(bordered ? Color.accentColor : Color.clear, lineWidth: 1) + ) + } + } + + private var orderListSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("최근 주문") + .font(.gmarketTitle2) + .foregroundColor(.text) + Spacer() + Button(action: { onNavigateOrderList() }) { + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + .padding(8) + } + } + + if viewModel.uiState.dashboardLoading { + HStack { Spacer(); ProgressView(); Spacer() } + .padding(.vertical, 32) + } else if let error = viewModel.uiState.dashboardError { + VStack { Text(error).foregroundColor(.red) } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if viewModel.uiState.orderList.isEmpty { + VStack { Text(StringResources.Order.emptyList).foregroundColor(.textSecondary) } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else { + VStack(spacing: 8) { + ForEach(viewModel.uiState.orderList, id: \.orderId) { order in + OrderItem(order: order) { onNavigateOrderDetail(order) } + } + } + } + } + } +} + + diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift new file mode 100644 index 0000000..f278418 --- /dev/null +++ b/SampoomManagement/Features/Dashboard/UI/DashboardViewModel.swift @@ -0,0 +1,50 @@ +// +// DashboardViewModel.swift +// SampoomManagement +// +// Created by Generated. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class DashboardViewModel: ObservableObject { + @Published var uiState = DashboardUiState() + + private let getOrderUseCase: GetOrderUseCase + + init(getOrderUseCase: GetOrderUseCase) { + self.getOrderUseCase = getOrderUseCase + loadOrderList() + } + + func onEvent(_ event: DashboardUiEvent) { + switch event { + case .loadDashboard, .retryDashboard: + loadOrderList() + } + } + + private func loadOrderList() { + Task { + uiState = uiState.copy(dashboardLoading: true, dashboardError: nil) + do { + let orderList = try await getOrderUseCase.execute() + uiState = uiState.copy( + orderList: Array(orderList.items.prefix(5)), + dashboardLoading: false, + dashboardError: nil + ) + } catch { + uiState = uiState.copy( + dashboardLoading: false, + dashboardError: error.localizedDescription + ) + } + } + } +} + + diff --git a/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json new file mode 100644 index 0000000..46b5fc4 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/money.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "money.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg b/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg new file mode 100644 index 0000000..efc6018 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/money.imageset/money.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json new file mode 100644 index 0000000..b9aa7c2 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "warning.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg new file mode 100644 index 0000000..c1c0ad7 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/warning.imageset/warning.svg @@ -0,0 +1,3 @@ + + + From 7e5bc8c08c5e7eae607799b9effbc9a737fdc08c Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Wed, 29 Oct 2025 17:38:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FIX]=20=EC=BD=94=EB=93=9C=20=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 9 +++++++-- .../Data/Repository/AuthRepositoryImpl.swift | 19 ++++++++++++++----- .../Dashboard/UI/DashboardUiState.swift | 2 +- .../Features/Dashboard/UI/DashboardView.swift | 7 +++++-- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index 8983ceb..63d6424 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -15,6 +15,7 @@ struct ContentView: View { // MARK: - Properties let dependencies: AppDependencies @StateObject private var partViewModel: PartViewModel + @StateObject private var dashboardViewModel: DashboardViewModel @State private var selectedTab: Tabs = .dashboard @State private var ordersNavigationPath = NavigationPath() @State private var partsNavigationPath = NavigationPath() @@ -23,6 +24,7 @@ struct ContentView: View { init(dependencies: AppDependencies) { self.dependencies = dependencies _partViewModel = StateObject(wrappedValue: dependencies.makePartViewModel()) + _dashboardViewModel = StateObject(wrappedValue: DashboardViewModel(getOrderUseCase: dependencies.getOrderUseCase)) } // MARK: - Body @@ -37,7 +39,7 @@ struct ContentView: View { Tab(value: .dashboard) { NavigationStack { DashboardView( - viewModel: DashboardViewModel(getOrderUseCase: dependencies.getOrderUseCase), + viewModel: dashboardViewModel, onLogoutClick: { Task { await dependencies.authViewModel.signOut() } }, @@ -49,7 +51,10 @@ struct ContentView: View { }, onNavigateOrderList: { selectedTab = .orders - } + }, + userName: ((try? dependencies.authPreferences.getStoredUser())?.name) ?? "", + workspace: ((try? dependencies.authPreferences.getStoredUser())?.workspace) ?? "", + branch: ((try? dependencies.authPreferences.getStoredUser())?.branch) ?? "" ) } } label: { diff --git a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift index 0a87456..2304aa9 100644 --- a/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift +++ b/SampoomManagement/Features/Auth/Data/Repository/AuthRepositoryImpl.swift @@ -53,12 +53,21 @@ class AuthRepositoryImpl: AuthRepository { throw AuthError.tokenSaveFailed(error) } - // 2) 프로필 조회 - let profileResponse = try await api.getProfile() - guard let profileDto = profileResponse.data else { - throw AuthError.invalidResponse + // 2) 프로필 조회 (실패 시 롤백) + let profileUser: User + do { + let profileResponse = try await api.getProfile() + guard let profileDto = profileResponse.data else { + // rollback tokens on invalid profile response + preferences.clear() + throw AuthError.invalidResponse + } + profileUser = profileDto.toModel() + } catch { + // rollback tokens on any profile failure + preferences.clear() + throw error } - let profileUser = profileDto.toModel() // 3) 병합 let mergedUser = loginUser.mergeWith(profile: profileUser) diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift index fdef142..3d4f100 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardUiState.swift @@ -30,7 +30,7 @@ struct DashboardUiState: Equatable { return DashboardUiState( orderList: orderList ?? self.orderList, dashboardLoading: dashboardLoading ?? self.dashboardLoading, - dashboardError: dashboardError + dashboardError: dashboardError ?? self.dashboardError ) } } diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift index 372eeb0..2660313 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -12,6 +12,9 @@ struct DashboardView: View { let onLogoutClick: () -> Void let onNavigateOrderDetail: (Order) -> Void let onNavigateOrderList: () -> Void + let userName: String + let workspace: String + let branch: String var isManager = true var body: some View { @@ -61,13 +64,13 @@ struct DashboardView: View { private var titleSection: some View { VStack(alignment: .leading, spacing: 16) { - Text("가산디지털단지점") + Text("\(workspace) \(branch)") .font(.gmarketTitle2) .fontWeight(.bold) .foregroundColor(.text) Group { - Text("안녕하세요, ") + Text("홍길동").foregroundColor(.accentColor) + Text(" 님") + Text("안녕하세요, ") + Text(userName).foregroundColor(.accentColor) + Text(" 님") } .font(.gmarketTitle) .fontWeight(.bold) From fd3127c3123d71fa9d57a0fc16482db810cf9be4 Mon Sep 17 00:00:00 2001 From: Sangyoon Date: Wed, 29 Oct 2025 17:47:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[REFAC]=20StringResource=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SampoomManagement/App/ContentView.swift | 1 - .../Core/Resources/StringResources.swift | 13 ++++++++++++ .../Features/Dashboard/UI/DashboardView.swift | 21 +++++++++---------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index 63d6424..acda9cc 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -53,7 +53,6 @@ struct ContentView: View { selectedTab = .orders }, userName: ((try? dependencies.authPreferences.getStoredUser())?.name) ?? "", - workspace: ((try? dependencies.authPreferences.getStoredUser())?.workspace) ?? "", branch: ((try? dependencies.authPreferences.getStoredUser())?.branch) ?? "" ) } diff --git a/SampoomManagement/Core/Resources/StringResources.swift b/SampoomManagement/Core/Resources/StringResources.swift index 7848069..a0acd05 100644 --- a/SampoomManagement/Core/Resources/StringResources.swift +++ b/SampoomManagement/Core/Resources/StringResources.swift @@ -14,6 +14,19 @@ struct StringResources { static let title = "SampoomManagement" } + // MARK: - Dashboard + struct Dashboard { + static let greetingPrefix = "안녕하세요, " + static let greetingSuffix = " 님" + static let intro = "오늘도 효율적인 재고 관리를 시작해보세요." + static let employee = "직원 관리" + static let partsOnHand = "보유 부품" + static let partsInProgress = "진행중 주문" + static let shortageOfParts = "부족 부품" + static let orderAmount = "주문 금액" + static let recentOrdersTitle = "최근 주문" + } + // MARK: - Tabs struct Tabs { static let dashboard = "대시보드" diff --git a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift index 2660313..eec1789 100644 --- a/SampoomManagement/Features/Dashboard/UI/DashboardView.swift +++ b/SampoomManagement/Features/Dashboard/UI/DashboardView.swift @@ -13,7 +13,6 @@ struct DashboardView: View { let onNavigateOrderDetail: (Order) -> Void let onNavigateOrderList: () -> Void let userName: String - let workspace: String let branch: String var isManager = true @@ -64,19 +63,19 @@ struct DashboardView: View { private var titleSection: some View { VStack(alignment: .leading, spacing: 16) { - Text("\(workspace) \(branch)") + Text("\(branch)") .font(.gmarketTitle2) .fontWeight(.bold) .foregroundColor(.text) Group { - Text("안녕하세요, ") + Text(userName).foregroundColor(.accentColor) + Text(" 님") + Text(StringResources.Dashboard.greetingPrefix) + Text(userName).foregroundColor(.accent) + Text(StringResources.Dashboard.greetingSuffix) } .font(.gmarketTitle) .fontWeight(.bold) .foregroundColor(.text) - Text("필요한 정보를 한 눈에 확인하세요.") + Text(StringResources.Dashboard.intro) .font(.gmarketBody) .foregroundColor(.text) } @@ -87,15 +86,15 @@ struct DashboardView: View { private var buttonSection: some View { VStack(spacing: 16) { if (isManager) { - buttonCard(iconName: "employee", valueText: "45", subText: "직원 관리", bordered: true) {} + buttonCard(iconName: "employee", valueText: "45", subText: StringResources.Dashboard.employee, bordered: true) {} } HStack(spacing: 16) { - buttonCard(iconName: "parts", valueText: "1234", subText: "보유 부품") {} - buttonCard(iconName: "orders", valueText: "23", subText: "진행중 주문") {} + buttonCard(iconName: "parts", valueText: "1234", subText: StringResources.Dashboard.partsOnHand) {} + buttonCard(iconName: "orders", valueText: "23", subText: StringResources.Dashboard.partsInProgress) {} } HStack(spacing: 16) { - buttonCard(iconName: "warning", valueText: "19", subText: "부족 부품") {} - buttonCard(iconName: "money", valueText: "4,123,200", subText: "주문 금액") {} + buttonCard(iconName: "warning", valueText: "19", subText: StringResources.Dashboard.shortageOfParts) {} + buttonCard(iconName: "money", valueText: "4,123,200", subText: StringResources.Dashboard.orderAmount) {} } } .padding(.bottom, 16) @@ -108,7 +107,7 @@ struct DashboardView: View { .renderingMode(.template) .foregroundStyle(Color.white) .padding(8) - .background(Color.accentColor) + .background(.accent) .clipShape(Circle()) Text(valueText) .font(.gmarketTitle2) @@ -131,7 +130,7 @@ struct DashboardView: View { private var orderListSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Text("최근 주문") + Text(StringResources.Dashboard.recentOrdersTitle) .font(.gmarketTitle2) .foregroundColor(.text) Spacer()