diff --git a/SampoomManagement/App/ContentView.swift b/SampoomManagement/App/ContentView.swift index dd6fb25..0b70a1d 100644 --- a/SampoomManagement/App/ContentView.swift +++ b/SampoomManagement/App/ContentView.swift @@ -15,6 +15,7 @@ struct ContentView: View { let dependencies: AppDependencies @StateObject private var partViewModel: PartViewModel @State private var selectedTab: Tabs = .dashboard + @State private var navigationPath = NavigationPath() init(dependencies: AppDependencies) { self.dependencies = dependencies @@ -43,6 +44,7 @@ struct ContentView: View { } label: { Label { Text(StringResources.Tabs.dashboard) + .font(.gmarketSubheadline) } icon: { Image("dashboard") .renderingMode(.template) @@ -70,6 +72,7 @@ struct ContentView: View { } label: { Label { Text(StringResources.Tabs.delivery) + .font(.gmarketSubheadline) } icon: { Image("delivery") .renderingMode(.template) @@ -97,6 +100,7 @@ struct ContentView: View { } label: { Label { Text(StringResources.Tabs.cart) + .font(.gmarketSubheadline) } icon: { Image("cart") .renderingMode(.template) @@ -124,6 +128,7 @@ struct ContentView: View { } label: { Label { Text(StringResources.Tabs.orders) + .font(.gmarketSubheadline) } icon: { Image("orders") .renderingMode(.template) @@ -133,11 +138,27 @@ struct ContentView: View { // PartView 탭 Tab(value: .parts, role: .search) { - PartView() - .environmentObject(partViewModel) + NavigationStack(path: $navigationPath) { + PartView( + onNavigatePartList: { group in + navigationPath.append(group.id) + }, + viewModel: partViewModel + ) + .navigationDestination(for: Int.self) { groupId in + PartListView( + viewModel: PartListViewModel( + getPartUseCase: dependencies.getPartUseCase, + groupId: groupId + ) + ) + } + } + .environmentObject(partViewModel) } label: { Label { Text(StringResources.Tabs.parts) + .font(.gmarketSubheadline) } icon: { Image("parts") .renderingMode(.template) diff --git a/SampoomManagement/App/RootView.swift b/SampoomManagement/App/RootView.swift index ca8f90b..da6de9d 100644 --- a/SampoomManagement/App/RootView.swift +++ b/SampoomManagement/App/RootView.swift @@ -23,7 +23,7 @@ struct RootView: View { var body: some View { Group { - if isAuthenticated { + if !isAuthenticated { // 로그인 되어있으면 메인 화면 ContentView(dependencies: dependencies) } else { diff --git a/SampoomManagement/Core/DI/AppDependencies.swift b/SampoomManagement/Core/DI/AppDependencies.swift index baa04af..9917d63 100644 --- a/SampoomManagement/Core/DI/AppDependencies.swift +++ b/SampoomManagement/Core/DI/AppDependencies.swift @@ -23,6 +23,8 @@ class AppDependencies { // MARK: - Part let partAPI: PartAPI let partRepository: PartRepository + let getCategoryUseCase: GetCategoryUseCase + let getGroupUseCase: GetGroupUseCase let getPartUseCase: GetPartUseCase init() { @@ -42,6 +44,8 @@ class AppDependencies { // Part partAPI = PartAPI(networkManager: networkManager) partRepository = PartRepositoryImpl(api: partAPI) + getCategoryUseCase = GetCategoryUseCase(repository: partRepository) + getGroupUseCase = GetGroupUseCase(repository: partRepository) getPartUseCase = GetPartUseCase(repository: partRepository) } @@ -56,7 +60,16 @@ class AppDependencies { } func makePartViewModel() -> PartViewModel { - return PartViewModel(getPartUseCase: getPartUseCase) + return PartViewModel( + getCategoryUseCase: getCategoryUseCase, + getGroupUseCase: getGroupUseCase + ) + } + + func makePartListViewModel(groupId: Int) -> PartListViewModel { + return PartListViewModel( + getPartUseCase: getPartUseCase, groupId: groupId + ) } } diff --git a/SampoomManagement/Core/UI/Components/EmptyView.swift b/SampoomManagement/Core/UI/Components/EmptyView.swift index 95e4a6d..16c7f9a 100644 --- a/SampoomManagement/Core/UI/Components/EmptyView.swift +++ b/SampoomManagement/Core/UI/Components/EmptyView.swift @@ -7,7 +7,7 @@ import SwiftUI -struct EmptyStateView: View { +struct EmptyView: View { let icon: String let title: String let message: String? @@ -46,11 +46,3 @@ struct EmptyStateView: View { } } } - -#Preview { - EmptyStateView( - icon: "tray", - title: "인벤토리가 비어있습니다", - message: "새로운 부품을 추가해보세요" - ) -} diff --git a/SampoomManagement/Core/UI/Components/ErrorView.swift b/SampoomManagement/Core/UI/Components/ErrorView.swift index 898e1b1..3c81c11 100644 --- a/SampoomManagement/Core/UI/Components/ErrorView.swift +++ b/SampoomManagement/Core/UI/Components/ErrorView.swift @@ -15,22 +15,15 @@ struct ErrorView: View { VStack(spacing: 16) { Spacer() - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 48)) - .foregroundColor(.red) - Text("오류가 발생했습니다") - .font(.headline) + .font(.gmarketHeadline) .foregroundColor(.red) - Text(error) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - - Button("다시 시도") { + Button { onRetry() + } label: { + Text("다시 시도") + .font(.gmarketCaption) } .buttonStyle(.borderedProminent) diff --git a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift index b7887ed..931395c 100644 --- a/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift +++ b/SampoomManagement/Features/Auth/Data/Mappers/AuthMappers.swift @@ -13,8 +13,6 @@ extension LoginResponseDTO { id: self.userId, name: self.userName, role: self.role, - accessToken: self.accessToken, - refreshToken: self.refreshToken, expiresIn: self.expiresIn ) } diff --git a/SampoomManagement/Features/Auth/Domain/Models/User.swift b/SampoomManagement/Features/Auth/Domain/Models/User.swift index c73011d..caefcd9 100644 --- a/SampoomManagement/Features/Auth/Domain/Models/User.swift +++ b/SampoomManagement/Features/Auth/Domain/Models/User.swift @@ -7,11 +7,9 @@ import Foundation -struct User: Identifiable, Codable, Equatable { +struct User: Equatable { let id: Int let name: String let role: String - let accessToken: String - let refreshToken: String let expiresIn: Int } diff --git a/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift b/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift index c27e5f5..19546ca 100644 --- a/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift +++ b/SampoomManagement/Features/Part/Data/Mappers/PartMappers.swift @@ -7,12 +7,34 @@ import Foundation +extension CategoryDTO { + func toModel() -> Category { + return Category( + id: self.id, + code: self.code, + name: self.name + ) + } +} + +extension GroupDTO { + func toModel() -> PartsGroup { + return PartsGroup( + id: self.id, + code: self.code, + name: self.name, + categoryId: self.categoryId + ) + } +} + extension PartDTO { func toModel() -> Part { return Part( - id: self.id, + id: self.partId, + code: self.code, name: self.name, - count: self.count + quantity: self.quantity ) } } diff --git a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift index 9def440..03a434c 100644 --- a/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift +++ b/SampoomManagement/Features/Part/Data/Remote/API/PartAPI.swift @@ -14,10 +14,46 @@ class PartAPI { self.networkManager = networkManager } - func getPartList() async throws -> PartList { + func getCategoryList() async throws -> CategoryList { return try await withCheckedThrowingContinuation { continuation in networkManager.request( - endpoint: "part", + endpoint: "agency/category", + responseType: [CategoryDTO].self + ) { result in + switch result { + case .success(let response): + let categories = response.data.map { $0.toModel() } + let categoryList = CategoryList(items: categories) + continuation.resume(returning: categoryList) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func getGroupList(categoryId: Int) async throws -> PartsGroupList { + return try await withCheckedThrowingContinuation { continuation in + networkManager.request( + endpoint: "agency/category/\(categoryId)", + responseType: [GroupDTO].self + ) { result in + switch result { + case .success(let response): + let groups = response.data.map { $0.toModel() } + let groupList = PartsGroupList(items: groups) + continuation.resume(returning: groupList) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func getPartList(groupId: Int) async throws -> PartList { + return try await withCheckedThrowingContinuation { continuation in + networkManager.request( + endpoint: "agency/1/group/\(groupId)", responseType: [PartDTO].self ) { result in switch result { diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift new file mode 100644 index 0000000..27dea61 --- /dev/null +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/CategoryDTO.swift @@ -0,0 +1,14 @@ +// +// CategoryDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct CategoryDTO: Codable { + let id: Int + let code: String + let name: String +} diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift new file mode 100644 index 0000000..c46b010 --- /dev/null +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/GroupDTO.swift @@ -0,0 +1,15 @@ +// +// GroupDTO.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct GroupDTO: Codable { + let id: Int + let code: String + let name: String + let categoryId: Int +} diff --git a/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift b/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift index 4f3c96c..73341bb 100644 --- a/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift +++ b/SampoomManagement/Features/Part/Data/Remote/DTO/PartDTO.swift @@ -8,7 +8,8 @@ import Foundation struct PartDTO: Codable { - let id: Int + let partId: Int + let code: String let name: String - let count: Int + let quantity: Int } diff --git a/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift b/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift index 740f47f..5df4cd6 100644 --- a/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift +++ b/SampoomManagement/Features/Part/Data/Repository/PartRepositoryImpl.swift @@ -14,7 +14,15 @@ class PartRepositoryImpl: PartRepository { self.api = api } - func getPartList() async throws -> PartList { - return try await api.getPartList() + func getCategoryList() async throws -> CategoryList { + return try await api.getCategoryList() + } + + func getGroupList(categoryId: Int) async throws -> PartsGroupList { + return try await api.getGroupList(categoryId: categoryId) + } + + func getPartList(groupId: Int) async throws -> PartList { + return try await api.getPartList(groupId: groupId) } } diff --git a/SampoomManagement/Features/Part/Domain/Models/Category.swift b/SampoomManagement/Features/Part/Domain/Models/Category.swift new file mode 100644 index 0000000..6273917 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/Category.swift @@ -0,0 +1,14 @@ +// +// Category.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct Category: Equatable { + let id: Int + let code: String + let name: String +} diff --git a/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift b/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift new file mode 100644 index 0000000..6a9ed4c --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/CategoryList.swift @@ -0,0 +1,22 @@ +// +// CategoryList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct CategoryList: Equatable { + let items: [Category] + var totalCount: Int { items.count } + var isEmpty: Bool { items.isEmpty } + + init(items: [Category]) { + self.items = items + } + + static func empty() -> CategoryList { + return CategoryList(items: []) + } +} diff --git a/SampoomManagement/Features/Part/Domain/Models/Part.swift b/SampoomManagement/Features/Part/Domain/Models/Part.swift index 3f3a96a..d381489 100644 --- a/SampoomManagement/Features/Part/Domain/Models/Part.swift +++ b/SampoomManagement/Features/Part/Domain/Models/Part.swift @@ -7,8 +7,9 @@ import Foundation -struct Part: Identifiable, Codable, Equatable { +struct Part: Equatable { let id: Int + let code: String let name: String - let count: Int + let quantity: Int } diff --git a/SampoomManagement/Features/Part/Domain/Models/PartList.swift b/SampoomManagement/Features/Part/Domain/Models/PartList.swift index 832c1e2..199b368 100644 --- a/SampoomManagement/Features/Part/Domain/Models/PartList.swift +++ b/SampoomManagement/Features/Part/Domain/Models/PartList.swift @@ -7,7 +7,7 @@ import Foundation -struct PartList: Codable, Equatable { +struct PartList: Equatable { let items: [Part] var totalCount: Int { items.count } var isEmpty: Bool { items.isEmpty } @@ -20,4 +20,3 @@ struct PartList: Codable, Equatable { return PartList(items: []) } } - diff --git a/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift b/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift new file mode 100644 index 0000000..c71a1c8 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/PartsGroup.swift @@ -0,0 +1,15 @@ +// +// PartsGroup.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartsGroup: Equatable { + let id: Int + let code: String + let name: String + let categoryId: Int +} diff --git a/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift b/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift new file mode 100644 index 0000000..151bec5 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/Models/PartsGroupList.swift @@ -0,0 +1,22 @@ +// +// PartsGroupList.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartsGroupList: Equatable { + let items: [PartsGroup] + var totalCount: Int { items.count } + var isEmpty: Bool { items.isEmpty } + + init(items: [PartsGroup]) { + self.items = items + } + + static func empty() -> PartsGroupList { + return PartsGroupList(items: []) + } +} diff --git a/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift b/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift index 3a703b1..1f8f0f5 100644 --- a/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift +++ b/SampoomManagement/Features/Part/Domain/Repository/PartRepository.swift @@ -8,5 +8,7 @@ import Foundation protocol PartRepository { - func getPartList() async throws -> PartList + func getCategoryList() async throws -> CategoryList + func getGroupList(categoryId: Int) async throws -> PartsGroupList + func getPartList(groupId: Int) async throws -> PartList } diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift new file mode 100644 index 0000000..52f2c36 --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetCategoryUseCase.swift @@ -0,0 +1,20 @@ +// +// GetCategoryUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +class GetCategoryUseCase { + private let repository : PartRepository + + init (repository: PartRepository) { + self.repository = repository + } + + func execute() async throws -> CategoryList { + return try await repository.getCategoryList() + } +} diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift new file mode 100644 index 0000000..0e8c20e --- /dev/null +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetGroupUseCase.swift @@ -0,0 +1,20 @@ +// +// GetGroupUseCase.swift +// SampoomManagement +// +// Created by 채상윤 on 9/29/25. +// + +import Foundation + +class GetGroupUseCase { + private let repository: PartRepository + + init(repository: PartRepository) { + self.repository = repository + } + + func execute(categoryId: Int) async throws -> PartsGroupList { + return try await repository.getGroupList(categoryId: categoryId) + } +} diff --git a/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift b/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift index 1e9fa32..98617f9 100644 --- a/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift +++ b/SampoomManagement/Features/Part/Domain/UseCase/GetPartUseCase.swift @@ -14,7 +14,7 @@ class GetPartUseCase { self.repository = repository } - func execute() async throws -> PartList { - return try await repository.getPartList() + func execute(groupId: Int) async throws -> PartList { + return try await repository.getPartList(groupId: groupId) } } diff --git a/SampoomManagement/Features/Part/UI/PartItemView.swift b/SampoomManagement/Features/Part/UI/PartItemView.swift deleted file mode 100644 index 6092fd8..0000000 --- a/SampoomManagement/Features/Part/UI/PartItemView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PartItemView.swift -// SampoomManagement -// -// Created by 채상윤 on 9/29/25. -// - -import SwiftUI - -struct PartItemView: View { - let part: Part - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(part.name) - .font(.headline) - .fontWeight(.semibold) - - Text("ID: \(part.id)") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Text("\(part.count)개") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(.blue) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - ) - } -} diff --git a/SampoomManagement/Features/Part/UI/PartListUiEvent.swift b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift new file mode 100644 index 0000000..f99c216 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListUiEvent.swift @@ -0,0 +1,13 @@ +// +// PartListUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum PartListUiEvent { + case loadPartList + case retryPartList +} diff --git a/SampoomManagement/Features/Part/UI/PartListUiState.swift b/SampoomManagement/Features/Part/UI/PartListUiState.swift new file mode 100644 index 0000000..e9bc38e --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListUiState.swift @@ -0,0 +1,36 @@ +// +// PartListUiState.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +struct PartListUiState { + let partList: [Part] + let partListLoading: Bool + let partListError: String? + + init( + partList: [Part] = [], + partListLoading: Bool = false, + partListError: String? = nil + ) { + self.partList = partList + self.partListLoading = partListLoading + self.partListError = partListError + } + + func copy( + partList: [Part]? = nil, + partListLoading: Bool? = nil, + partListError: String? = nil + ) -> PartListUiState { + return PartListUiState( + partList: partList ?? self.partList, + partListLoading: partListLoading ?? self.partListLoading, + partListError: partListError ?? self.partListError + ) + } +} diff --git a/SampoomManagement/Features/Part/UI/PartListView.swift b/SampoomManagement/Features/Part/UI/PartListView.swift new file mode 100644 index 0000000..f376226 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListView.swift @@ -0,0 +1,94 @@ +// +// PartListView.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import SwiftUI + +struct PartListView: View { + @ObservedObject var viewModel: PartListViewModel + + init( + viewModel: PartListViewModel + ) { + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + if viewModel.uiState.partListLoading { + // 로딩 상태 + ProgressView() + .frame(width: .infinity, height: .infinity) + .background(Color.background) + } else if let error = viewModel.uiState.partListError { + // 에러 상태 + Spacer() + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryPartList) } + ) + .frame(height: 200) + Spacer() + } else if viewModel.uiState.partList.isEmpty { + // 빈 상태 + Spacer() + EmptyView( + icon: "tray", + title: "부품이 없습니다" + ) + .frame(height: 200) + Spacer() + } else { + // 부품 리스트 + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.uiState.partList, id: \.id) { part in + PartListItemCard(part: part) + } + } + .padding(16) + } + } + } + .navigationTitle("부품조회") + .navigationBarTitleDisplayMode(.automatic) + .background(Color.background) + } +} + +struct PartListItemCard: View { + let part: Part + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(part.name) + .font(.gmarketTitle3) + .foregroundColor(.text) + + Text(part.code) + .font(.gmarketCaption) + .foregroundColor(.textSecondary) + } + + Spacer() + + Text("\(part.quantity)") + .font(.gmarketTitle3) + .foregroundColor(.text) + + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.backgroundCard) + ) + } +} + + diff --git a/SampoomManagement/Features/Part/UI/PartListViewModel.swift b/SampoomManagement/Features/Part/UI/PartListViewModel.swift new file mode 100644 index 0000000..f5f814b --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartListViewModel.swift @@ -0,0 +1,73 @@ +// +// PartListViewModel.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation +import SwiftUI +import Combine + +@MainActor +class PartListViewModel: ObservableObject { + @Published var uiState = PartListUiState() + + private let getPartUseCase: GetPartUseCase + private let groupId: Int + private var loadTask: Task? + + init( + getPartUseCase: GetPartUseCase, + groupId: Int + ) { + self.getPartUseCase = getPartUseCase + self.groupId = groupId + + loadPartList() + } + + func onEvent(_ event: PartListUiEvent) { + switch event { + case .loadPartList:loadPartList() + case .retryPartList:loadPartList() + } + } + + private func loadPartList() { + // 이전 작업 취소 + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { return } + // 로딩 상태 진입은 메인에서 + await MainActor.run { + self.uiState = self.uiState.copy(partListLoading: true, partListError: nil) + } + do { + let partList = try await self.getPartUseCase.execute(groupId: self.groupId) + try Task.checkCancellation() + await MainActor.run { + self.uiState = self.uiState.copy( + partList: partList.items, + partListLoading: false, + partListError: nil + ) + } + } catch is CancellationError { + // 취소는 무시 + } catch { + await MainActor.run { + self.uiState = self.uiState.copy( + partListLoading: false, + partListError: error.localizedDescription + ) + } + } + #if DEBUG + await MainActor.run { + print("PartListViewModel - loadPartList: \(self.uiState)") + } + #endif + } + } +} diff --git a/SampoomManagement/Features/Part/UI/PartUIState.swift b/SampoomManagement/Features/Part/UI/PartUIState.swift index 8f5e289..01b0256 100644 --- a/SampoomManagement/Features/Part/UI/PartUIState.swift +++ b/SampoomManagement/Features/Part/UI/PartUIState.swift @@ -7,35 +7,57 @@ import Foundation -struct PartUIState: UIState { - let loading: Bool - let error: String? - let success: Bool - let partList: [Part] +struct PartUIState { + // Part + let groupList: [PartsGroup] + let groupLoading: Bool + let groupError: String? + + let selectedCategory: Category? + + // Category + let categoryList: [Category] + let categoryLoading: Bool + let categoryError: String? init( - loading: Bool = false, - error: String? = nil, - success: Bool = false, - partList: [Part] = [] + groupList: [PartsGroup] = [], + groupLoading: Bool = false, + groupError: String? = nil, + selectedCategory: Category? = nil, + categoryList: [Category] = [], + categoryLoading: Bool = false, + categoryError: String? = nil ) { - self.loading = loading - self.error = error - self.success = success - self.partList = partList + self.groupList = groupList + self.groupLoading = groupLoading + self.groupError = groupError + + self.selectedCategory = selectedCategory + + self.categoryList = categoryList + self.categoryLoading = categoryLoading + self.categoryError = categoryError } func copy( - loading: Bool? = nil, - error: String? = nil, - success: Bool? = nil, - partList: [Part]? = nil + groupList: [PartsGroup]? = nil, + groupLoading: Bool? = nil, + groupError: String?? = nil, + selectedCategory: Category?? = nil, + categoryList: [Category]? = nil, + categoryLoading: Bool? = nil, + categoryError: String?? = nil ) -> PartUIState { return PartUIState( - loading: loading ?? self.loading, - error: error ?? self.error, - success: success ?? self.success, - partList: partList ?? self.partList + groupList: groupList ?? self.groupList, + groupLoading: groupLoading ?? self.groupLoading, + groupError: groupError ?? self.groupError, + selectedCategory: selectedCategory ?? self.selectedCategory, + categoryList: categoryList ?? self.categoryList, + categoryLoading: categoryLoading ?? self.categoryLoading, + categoryError: categoryError ?? self.categoryError ) } } + diff --git a/SampoomManagement/Features/Part/UI/PartUiEvent.swift b/SampoomManagement/Features/Part/UI/PartUiEvent.swift new file mode 100644 index 0000000..19df5b0 --- /dev/null +++ b/SampoomManagement/Features/Part/UI/PartUiEvent.swift @@ -0,0 +1,15 @@ +// +// PartUiEvent.swift +// SampoomManagement +// +// Created by 채상윤 on 10/17/25. +// + +import Foundation + +enum PartUiEvent { + case loadCategories + case categorySelected(Category) + case retryCategories + case retryGroups +} diff --git a/SampoomManagement/Features/Part/UI/PartView.swift b/SampoomManagement/Features/Part/UI/PartView.swift index aee7f01..1551dd2 100644 --- a/SampoomManagement/Features/Part/UI/PartView.swift +++ b/SampoomManagement/Features/Part/UI/PartView.swift @@ -8,58 +8,214 @@ import SwiftUI struct PartView: View { - @EnvironmentObject var viewModel: PartViewModel - @State var searchString = "" + @ObservedObject var viewModel: PartViewModel + @State private var searchQuery = "" + + let onNavigatePartList: (PartsGroup) -> Void + + init( + onNavigatePartList: @escaping (PartsGroup) -> Void, + viewModel: PartViewModel + ) { + self.onNavigatePartList = onNavigatePartList + self.viewModel = viewModel + } var body: some View { - NavigationStack { - VStack(spacing: 0) { - contentView + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Category 선택 제목 + Text("카테고리 선택") + .font(.gmarketTitle2) + .fontWeight(.bold) + .padding(.horizontal, 16) + + // Category 섹션 + categorySection + + Spacer() + .frame(height: 24) + + // 그룹 리스트 섹션 + groupSection } - .navigationBarTitle(Text("부품")) - .searchable(text: $searchString) + .padding(.vertical, 16) } + .navigationTitle("부품조회") + .navigationBarTitleDisplayMode(.automatic) + .searchable(text: $searchQuery, prompt: "search") + .background(Color.background) } @ViewBuilder - private var contentView: some View { - if viewModel.uiState.loading { - loadingView - } else if let error = viewModel.uiState.error { - errorView(error: error) - } else if viewModel.uiState.partList.isEmpty { - emptyView + private var categorySection: some View { + if viewModel.uiState.categoryLoading { + // 로딩 상태 + HStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 200) + } else if let error = viewModel.uiState.categoryError { + // 에러 상태 + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryCategories) } + ) + .frame(height: 200) + } else if viewModel.uiState.categoryList.isEmpty { + // 빈 상태 + EmptyView( + icon: "tray", + title: "카테고리가 없습니다" + ) + .frame(height: 200) } else { - listView + // 카테고리 그리드 + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { + ForEach(viewModel.uiState.categoryList, id: \.id) { category in + CategoryItem( + category: category, + isSelected: category.id == viewModel.uiState.selectedCategory?.id, + onClick: { + viewModel.onEvent(.categorySelected(category)) + } + ) + } + } + .padding(.horizontal, 16) } } - private var loadingView: some View { - LoadingView() + @ViewBuilder + private var groupSection: some View { + if viewModel.uiState.selectedCategory == nil { + // 초기 상태: 카테고리 선택 안내 + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 32)) + .foregroundColor(.gray) + + Text("카테고리를 선택해주세요") + .font(.gmarketBody) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else { + // 그룹 선택 제목 + Text("그룹 선택") + .font(.gmarketTitle2) + .fontWeight(.bold) + .padding(.horizontal, 16) + + // 그룹 리스트 + if viewModel.uiState.groupLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 200) + } else if let error = viewModel.uiState.groupError { + ErrorView( + error: error, + onRetry: { viewModel.onEvent(.retryGroups) } + ) + .frame(height: 200) + } else if viewModel.uiState.groupList.isEmpty { + HStack { + Spacer() + EmptyView( + icon: "tray", + title: "그룹이 없습니다" + ) + Spacer() + } + .frame(height: 200) + } else { + LazyVStack(spacing: 8) { + ForEach(viewModel.uiState.groupList, id: \.id) { group in + PartItemCard( + group: group, + onClick: { onNavigatePartList(group) } + ) + } + } + .padding(.horizontal, 16) + } + } } +} + + +// Category 아이템 +struct CategoryItem: View { + let category: Category + let isSelected: Bool + let onClick: () -> Void - private func errorView(error: String) -> some View { - ErrorView(error: error) { - viewModel.refreshPart() + var body: some View { + Button(action: onClick) { + VStack(spacing: 4) { + Image(categoryIcon(for: category.code)) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? .white : .text) + + Text(category.name) + .font(.gmarketCaption) + .foregroundColor(isSelected ? .white : .text) + } + .frame(height: 100) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected ? .accent : .backgroundCard) + ) } + .buttonStyle(PlainButtonStyle()) } - private var emptyView: some View { - EmptyStateView( - icon: "tray", - title: "인벤토리가 비어있습니다" - ) + private func categoryIcon(for code: String) -> String { + switch code { + case "ENG": return "engine" + case "TRN": return "transmission" + case "CHS": return "chassis" + case "BDY": return "body" + case "TRM": return "trim" + case "ELE": return "electric" + default: return "parts" + } } +} + +// Part 아이템 카드 +struct PartItemCard: View { + let group: PartsGroup + let onClick: () -> Void - private var listView: some View { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(viewModel.uiState.partList) { part in - PartItemView(part: part) - } + var body: some View { + Button(action: onClick) { + HStack { + Text(group.name) + .font(.gmarketBody) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.gray) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color(.backgroundCard)) + ) } + .buttonStyle(PlainButtonStyle()) } } diff --git a/SampoomManagement/Features/Part/UI/PartViewModel.swift b/SampoomManagement/Features/Part/UI/PartViewModel.swift index 1297624..2072ab4 100644 --- a/SampoomManagement/Features/Part/UI/PartViewModel.swift +++ b/SampoomManagement/Features/Part/UI/PartViewModel.swift @@ -12,36 +12,84 @@ import Combine @MainActor class PartViewModel: ObservableObject { @Published var uiState = PartUIState() + + private let getCategoryUseCase: GetCategoryUseCase + private let getGroupUseCase: GetGroupUseCase - private let getPartUseCase: GetPartUseCase + init( + getCategoryUseCase: GetCategoryUseCase, + getGroupUseCase: GetGroupUseCase + ) { + self.getCategoryUseCase = getCategoryUseCase + self.getGroupUseCase = getGroupUseCase + loadCategory() + } - init(getPartUseCase: GetPartUseCase) { - self.getPartUseCase = getPartUseCase - loadPart() + func onEvent(_ event: PartUiEvent) { + switch event { + case .loadCategories: + loadCategory() + case .categorySelected(let category): + selectCategory(category) + case .retryCategories: + loadCategory() + case .retryGroups: + loadGroup() + } } - private func loadPart() { + private func loadCategory() { Task { - uiState = uiState.copy(loading: true, error: nil) + uiState = uiState.copy(categoryLoading: true, categoryError: .some(nil)) do { - let partList = try await getPartUseCase.execute() + let categoryList = try await getCategoryUseCase.execute() uiState = uiState.copy( - loading: false, - success: true, - partList: partList.items + categoryList: categoryList.items, + categoryLoading: false, + categoryError: .some(nil) ) } catch { uiState = uiState.copy( - loading: false, - error: error.localizedDescription + categoryLoading: false, + categoryError: error.localizedDescription ) } + print("PartViewModel - loadCategory: \(uiState)") + } + } + + private func selectCategory(_ category: Category) { + Task { + uiState = uiState.copy(selectedCategory: category) + await loadGroup(categoryId: category.id) + } + } + + private func loadGroup(categoryId: Int) async { + uiState = uiState.copy(groupLoading: true, groupError: .some(nil)) + + do { + let groupList = try await getGroupUseCase.execute(categoryId: categoryId) + uiState = uiState.copy( + groupList: groupList.items, + groupLoading: false, + groupError: .some(nil) + ) + } catch { + uiState = uiState.copy( + groupLoading: false, + groupError: error.localizedDescription + ) } + print("PartViewModel - loadGroup: \(uiState)") } - func refreshPart() { - loadPart() + private func loadGroup() { + guard let selectedCategory = uiState.selectedCategory else { return } + Task { + await loadGroup(categoryId: selectedCategory.id) + } } } diff --git a/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json new file mode 100644 index 0000000..01be4d5 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/body.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "body.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg b/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg new file mode 100644 index 0000000..17e3dc5 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/body.imageset/body.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json new file mode 100644 index 0000000..71d0d2f --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "chassis.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg new file mode 100644 index 0000000..8311790 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/chassis.imageset/chassis.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json new file mode 100644 index 0000000..984f80c --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "electric.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg new file mode 100644 index 0000000..cceeca9 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/electric.imageset/electric.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json new file mode 100644 index 0000000..2ae59ca --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "engine.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg new file mode 100644 index 0000000..27a13bf --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/engine.imageset/engine.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json new file mode 100644 index 0000000..5919728 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "transmission.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg new file mode 100644 index 0000000..70d8691 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/transmission.imageset/transmission.svg @@ -0,0 +1,3 @@ + + + diff --git a/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json new file mode 100644 index 0000000..f51b710 --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "trim.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg new file mode 100644 index 0000000..28e579a --- /dev/null +++ b/SampoomManagement/Resources/Assets.xcassets/trim.imageset/trim.svg @@ -0,0 +1,10 @@ + + + + + + + + + +