diff --git a/StockMate/StockMate/app/core/components/InventoryCardView.swift b/StockMate/StockMate/app/core/components/InventoryCardView.swift new file mode 100644 index 0000000..7113417 --- /dev/null +++ b/StockMate/StockMate/app/core/components/InventoryCardView.swift @@ -0,0 +1,91 @@ +// +// InventoryCardView.swift +// StockMate +// +// Created by Admin on 10/16/25. +// + +import SwiftUI + + +struct InventoryCardView: View { + let item: InventoryItem + var showStatus: Bool = true // 필요 시 상태 표시 숨기기 옵션 + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + // 상단 카테고리 + Text(item.categoryName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + + Divider() + .frame(height: 0.2) + .background(Color.textGray2) + + HStack(alignment: .center, spacing: 12) { + // 제품 이미지 + AsyncImage(url: URL(string: item.image)) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + // 제품명, 트림/모델 + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.korName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + .lineLimit(2) + Spacer() + } + .padding(.top, 2) + + Text("\(item.trim) / \(item.model)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.gray) + } + .frame(height: 60, alignment: .top) + + // 상태 표시 (옵션) + if showStatus { + VStack(alignment: .center, spacing: 6) { + if item.isLack { + Text("수량 부족") + .font(.system(size: 13, weight: .regular)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.DangerBg) + .foregroundColor(.Danger) + .cornerRadius(12) + } else { + Text("수량 여유") + .font(.system(size: 13, weight: .regular)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.StatusGreenBg) + .foregroundColor(.StatusGreen) + .cornerRadius(12) + } + + VStack(alignment: .leading) { + Text("현재수량: \(item.amount)개") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.textGray1) + Text("최소수량: \(item.limitAmount)개") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.textGray1) + } + } + } + } + } + .padding() + .background(Color.white) + .cornerRadius(10) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + } +} diff --git a/StockMate/StockMate/app/core/components/InventoryListSection.swift b/StockMate/StockMate/app/core/components/InventoryListSection.swift new file mode 100644 index 0000000..c8d6c19 --- /dev/null +++ b/StockMate/StockMate/app/core/components/InventoryListSection.swift @@ -0,0 +1,18 @@ +// +// InventoryListSection.swift +// StockMate +// +// Created by Admin on 10/16/25. +// + +import SwiftUI + +struct InventoryListSection: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + InventoryListSection() +} diff --git a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift index 23da13f..06e87e3 100644 --- a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift @@ -18,7 +18,6 @@ struct LoginView: View { @State private var emailError: String? = nil @State private var pwError: String? = nil -// var onLoginSuccess: () -> Void = {} var onLogin: (String, String) -> Void = { _, _ in } var onClickRegister: () -> Void = {} diff --git a/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift b/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift new file mode 100644 index 0000000..ed33182 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift @@ -0,0 +1,83 @@ +// +// InventoryApi.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + + +import Foundation +import Alamofire + +struct InventoryResponse: Decodable { + let status: Int + let success: Bool + let message: String + let data: InventoryPageData? +} + +struct InventoryPageData: Decodable { + let content: [InventoryItem] + let page: Int + let size: Int + let totalElements: Int + let totalPages: Int +} + +struct InventoryItem: Decodable, Identifiable { + let id: Int + let name: String + let price: Int + let image: String + let trim: String + let model: String + let category: Int + let korName: String + let engName: String + let categoryName: String + let stock: Int // 본사 재고 + let amount: Int // 지점별 재고 + let limitAmount: Int // 지점별 최소 수량 + let isLack: Bool +} + +enum InventoryApi { + static func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) -> DataRequest { + var url = ApiClient.baseURL + "api/v1/store/search?page=\(page)&size=\(size)" + + for c in categoryNames { + url += "&categoryName=\(c.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + for t in trims { + url += "&trim=\(t.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + for m in models { + url += "&model=\(m.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + + return ApiClient.shared.request(url, method: .get) + } + + // ✅ 부족 재고 조회 API 추가 + static func getUnderLimitList(categoryName: String? = nil, page: Int = 0, size: Int = 10) -> DataRequest { + var url = ApiClient.baseURL + "api/v1/store/under-limit?page=\(page)&size=\(size)" + if let categoryName = categoryName, !categoryName.isEmpty { + url += "&categoryName=\(categoryName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + return ApiClient.shared.request(url, method: .get) + } + + // ✅ 부품 이름으로 검색 + static func findByName(name: String, page: Int, size: Int) -> DataRequest { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = ApiClient.baseURL + "api/v1/store/find-name?name=\(encodedName)&page=\(page)&size=\(size)" + return ApiClient.shared.request(url, method: .get) + } + +} diff --git a/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift b/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift new file mode 100644 index 0000000..914e732 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift @@ -0,0 +1,49 @@ +// +// InventoryRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import Foundation +import Alamofire + +final class InventoryRepositoryImpl: InventoryRepositoryProtocol { + func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) async -> AppResult> { + let dataReq = InventoryApi.getInventoryList( + page: page, + size: size, + categoryNames: categoryNames, + trims: trims, + models: models + ) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + // 부족 재고 리스트 호출 + func getUnderLimitList( + categoryName: String?, + page: Int, + size: Int + ) async -> AppResult> { + let dataReq = InventoryApi.getUnderLimitList(categoryName: categoryName, page: page, size: size) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + // ✅ 이름 검색 + func findByName( + name: String, + page: Int, + size: Int + ) async -> AppResult> { + let dataReq = InventoryApi.findByName(name: name, page: page, size: size) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + +} diff --git a/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift b/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift new file mode 100644 index 0000000..3d45edd --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift @@ -0,0 +1,32 @@ +// +// InventoryRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import Foundation + +protocol InventoryRepositoryProtocol { + func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) async -> AppResult> + + func getUnderLimitList( + categoryName: String?, + page: Int, + size: Int + ) async -> AppResult> + + // 이름 검색 + func findByName( + name: String, + page: Int, + size: Int + ) async -> AppResult> + +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventorySearchView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventorySearchView.swift new file mode 100644 index 0000000..000cc5c --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/InventorySearchView.swift @@ -0,0 +1,227 @@ +// +// InventorySearchView.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + + +import SwiftUI + +struct InventorySearchView: View { + @StateObject private var inventoryViewModel = InventoryViewModel() + @State private var searchText = "" + + // 카테고리, 분류, 모델 + private let categories = ["전기/램프", "엔진/미션", "하체/바디", "내장/외장", "기타소모품"] + private let trims = ["준중형/소형", "중형", "대형", "SUV", "화물/트럭/승합", "수소/전기"] + private let trimToModels: [String: [String]] = [ + "준중형/소형": [ "아반떼MD", "아반떼AD", "아반떼CN7", "I30", "엑센트", "아이오닉", "벨로스터", "캐스퍼" ], + "중형": [ "NF소나타", "YF소나타", "LF소나타", "DN8소나타", "그랜저TG", "그랜저HG", "그랜저IG", "그랜저GN7", "I40" ], + "대형": ["제네시스BH", "에쿠스"], + "SUV": [ "베뉴", "코나OS", "코나SX2", "투싼IX", "투싼TL", "투싼NX4", "싼타페CM", "싼타페DM", "싼타페TM", "싼타페MX5", "맥스크루즈", "베라크루즈", "팰리세이드LX2", "팰리세이드LX3" ], + "화물/트럭/승합": [ "스타렉스", "그랜드스타렉스", "스타리아", "포터2", "쏠라티", "마이티", "메가트럭", "카운티" ], + "수소/전기": ["아이오닉5", "아이오닉6", "아이오닉9", "넥쏘FE", "넥쏘NH2"] + ] + + private var filteredModels: [String] { + if inventoryViewModel.selectedTrims.isEmpty { + return trimToModels.values.flatMap { $0 } + } else { + return inventoryViewModel.selectedTrims + .flatMap { trimToModels[$0] ?? [] } + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // 🔍 검색창 + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("부품을 검색하세요.", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .onSubmit { + let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + Task { +// await inventoryViewModel.searchByName(name: searchText, reset: true) + await inventoryViewModel.searchByName(name: term, reset: true) + } + } + + if !searchText.isEmpty { + Button(action: { + searchText = "" + inventoryViewModel.isSearching = false + }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + } + .buttonStyle(.plain) + .padding(.trailing,3) + } + } + .padding() + .background(Color(.white)) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + ) + .padding(.horizontal) + .padding(.vertical) + + // 필터 및 초기화 버튼 + HStack(spacing: 10) { + FilterMenu( + title: "카테고리", + items: categories, + selectedItems: inventoryViewModel.selectedCategories, + onTap: { inventoryViewModel.toggleCategory($0) } + ) + + FilterMenu( + title: "분류", + items: trims, + selectedItems: inventoryViewModel.selectedTrims, + onTap: { inventoryViewModel.toggleTrim($0) } + ) + + FilterMenu( + title: "모델", + items: filteredModels, + selectedItems: inventoryViewModel.selectedModels, + onTap: { inventoryViewModel.toggleModel($0) } + ) + + // 🔄 초기화 버튼 + Button(action: { + inventoryViewModel.resetFilters(with: searchText) + }) { + HStack(spacing: 4) { + Image(systemName: "arrow.counterclockwise") + Text("초기화") + } + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.blue) + .padding(.trailing, 8) + } + .frame(maxWidth: .infinity, alignment: .trailing) + + } + .padding(.horizontal) + .padding(.bottom, 16) + + // 📋 재고 리스트 + ScrollView { + LazyVStack(spacing: 10) { + + ForEach( + inventoryViewModel.isSearching + ? inventoryViewModel.filteredSearchResults // 검색 + 필터링 + : inventoryViewModel.inventoryItems + ) { item in + InventoryCardView(item: item) + .padding(.horizontal) + .onAppear { + Task { + if inventoryViewModel.isSearching { + if item.id == inventoryViewModel.filteredSearchResults.last?.id, + inventoryViewModel.searchHasMore { + await inventoryViewModel.loadMore(searchText: searchText) + } + } else { + if item.id == inventoryViewModel.inventoryItems.last?.id, + inventoryViewModel.hasMore { + await inventoryViewModel.loadMore(searchText: searchText) + } + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical) + } + } + } + } + .background(Color.Light) + .navigationTitle("재고 조회") + .task { + await inventoryViewModel.loadInventoryList(reset: true) + } + } + } +} + +// 필터 버튼 공용 컴포넌트 +struct FilterMenu: View { + let title: String + let items: [String] + let selectedItems: [String] + let onTap: (String) -> Void + + var displayTitle: String { + if selectedItems.isEmpty { + return title + } else if selectedItems.count == 1 { + return selectedItems.first ?? title + } else { + return "\(title) (\(selectedItems.count))" + } + } + + var isActive: Bool { !selectedItems.isEmpty } + + var truncatedTitle: String { + // 글자 6자까지만 표시, 이후 "..." 처리 + if displayTitle.count > 8 { + let prefix = displayTitle.prefix(8) + return "\(prefix)…" + } + return displayTitle + } + + var body: some View { + Menu { + ForEach(items, id: \.self) { item in + Button { + onTap(item) + } label: { + HStack { + Text(item) + if selectedItems.contains(item) { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(isActive ? .blue : .gray) + + Text(truncatedTitle) + .font(.system(size: 13)) + .foregroundColor(isActive ? .blue : .black) + .lineLimit(1) + .truncationMode(.tail) // 안전하게 "..." 처리 + .multilineTextAlignment(.center) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) // 높이 늘림 + .background( + isActive ? Color.blue.opacity(0.2) : Color(.systemGray6) + ) + .cornerRadius(8) + } + } +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift index 96756f0..b0d3132 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift @@ -6,168 +6,180 @@ // import SwiftUI - struct InventoryView: View { + @Binding var selectedTab: Int + @Binding var tabTappedTrigger: Bool + + @StateObject private var inventoryViewModel = InventoryViewModel() + @State private var showScrollToTopButton = false + @Namespace private var topID + + @State private var lastTabSelection = 0 + var body: some View { NavigationStack { - VStack(alignment: .leading, spacing: 13) { - Text("재고 관리") - .font(.title2) - .bold() - .padding(.top, 13) - .frame(maxWidth: .infinity, alignment: .center) - - // 4개 버튼 영역 - GridMenuView() - - // 섹션 타이틀 - Text("얼마 남지 않았어요!") - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(.black) - .padding(.horizontal, 25) - .padding(.top) - - // 공통 컴포넌트 리스트 - ScrollView { - VStack(spacing: 12) { - // 예시 데이터 - let dummyData: [(String, String, Int, Int)] = [ - ("브레이크", "현대 아이오닉5", 5, 10), - ("엔진오일", "기아 EV6", 10, 12), - ("에어필터", "현대 코나", 8, 10), - ] + ScrollViewReader { proxy in + ZStack { + ScrollView { + VStack(spacing: 0) { + // 스크롤 감지용 (맨 위) + GeometryReader { geo in + Color.clear + .onChange(of: geo.frame(in: .global).minY) { newValue in + // 👇 스크롤 시 값이 변함 + //print("📏 Scroll offsetY:", newValue) // 테스트용, 화면 안정화 후 제거 + withAnimation(.easeInOut(duration: 0.25)) { + showScrollToTopButton = newValue < -150 + } + } + } + .frame(height: 0) + .id(topID) + + // 타이틀 + Text("재고 관리") + .font(.title2) + .bold() + .padding(.top, 13) + .padding(.leading, 25) + .frame(maxWidth: .infinity, alignment: .leading) + + GridMenuView() + + Text("얼마 남지 않았어요!") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 25) + .padding(.top) + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVStack(spacing: 12) { + ForEach(inventoryViewModel.underLimitItems) { item in + InventoryCardView(item: item) + .padding(.horizontal) + .onAppear { + if item.id == inventoryViewModel.underLimitItems.last?.id { + Task { + await inventoryViewModel.loadUnderLimitList() + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical, 20) + } + } + .padding(.top, 8) + .padding(.bottom, 80) + } + } - ForEach(dummyData, id: \.0) { item in - StockShortageCard( - partName: item.0, - carModel: item.1, - currentCount: item.2, - minCount: item.3 - ) + // 오른쪽 하단 플로팅 버튼 + if showScrollToTopButton { + VStack { + Spacer() + HStack { + Spacer() + Button { + withAnimation(.easeInOut) { + proxy.scrollTo(topID, anchor: .top) + } + } label: { + ZStack { + Circle() + .fill(Color.Secondary) // 배경색 + .frame(width: 50, height: 50) + Image(systemName: "arrow.up") + .font( + .system(size: 24, weight: .bold) + ) + .foregroundColor(.white) // 화살표 색 + } + } + .padding(.trailing, 20) + .padding(.bottom, 20) + } } + .transition(.opacity) + .animation(.easeInOut(duration: 0.25), value: showScrollToTopButton) } - .padding(.horizontal) } + .background(Color.Light) + .task { + await inventoryViewModel.loadUnderLimitList(reset: true) + } + // 같은 탭 다시 눌릴 때 위로 스크롤 + .onChange(of: tabTappedTrigger) { _ in + withAnimation(.easeInOut) { + proxy.scrollTo(topID, anchor: .top) + } + } } - .background(Color.Light) } } } struct GridMenuView: View { let menuItems = [ - ("재고조회", Color.InvIncoming, Color.InvIncomingBg, AnyView(IncomingScanView())), - ("입출고 히스토리", Color.InvUse, Color.InvUseBg, AnyView(IncomingScanView())), - ("입고처리", Color.Transfer, Color.TransferBg, AnyView(IncomingScanView())), - ("사용처리", Color.InvStock, Color.InvStockBg, AnyView(IncomingScanView())), + ("재고 조회", true, "InvStock", AnyView(InventorySearchView())), + ("입출고 히스토리", false, "InvTrans", AnyView(IncomingScanView())), + ("입고 처리", false, "InvIncoming", AnyView(IncomingScanView())), + ("사용 처리", true, "InvUse", AnyView(IncomingScanView())), ] - + var body: some View { - VStack(spacing: 20) { + VStack(spacing: 15) { LazyVGrid( columns: [ - GridItem(.flexible(), spacing: 15), - GridItem(.flexible(), spacing: 15), + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), ], - spacing: 15 + spacing: 10 ) { - ForEach(menuItems, id: \.0) { item in - ZStack { - // 그림자 전용 레이어 (NavigationLink 뒤에 위치) - RoundedRectangle(cornerRadius: 16) - .fill(Color.white) - .shadow( - color: .black.opacity(0.25), - radius: 4, - x: 0, - y: 4 - ) - - // 버튼 본체 - NavigationLink(destination: item.3) { - VStack(spacing: 12) { - Image("InvStock") - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(item.1) - .padding(.top, 20) - - Text(item.0) - .font(.system(size: 15, weight: .semibold)) - .foregroundColor(item.1) - .padding(.bottom, 20) + NavigationLink(destination: item.3) { + VStack(alignment: .leading, spacing: 19) { + HStack { + Spacer() + ZStack { + // 타원 배경 + Rectangle() + .fill(item.1 ? Color.white.opacity(0.2) : Color.Secondary) + .frame(width: 35, height: 26) + .cornerRadius(80) + + // 아이콘 + Image(item.2) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .foregroundColor(.white) + } } - .frame(maxWidth: .infinity, minHeight: 120) - .background(item.2.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(item.1, lineWidth: 1.2) - ) - .cornerRadius(16) + + Text(item.0) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(item.1 ? .white : Color.Secondary) } - .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 20) + .padding(.vertical, 20) + .frame(height: 99) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(item.1 ? Color.Secondary : Color.white) + // 카드 그림자 (Figma 스펙: y=4, blur=4, opacity=25%, black) + .shadow(color: .black.opacity(0.35), radius: 2, x: 0, y: 4) + ) } + .buttonStyle(.plain) } } - .padding(.horizontal, 10) + .padding(.horizontal, 15) } - .padding() - .background(Color.White) - .padding(.horizontal) + .padding(.vertical, 17) } } - -struct StockShortageCard: View { - let partName: String - let carModel: String - let currentCount: Int - let minCount: Int - - var body: some View { - HStack(alignment: .center, spacing: 12) { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.2)) - .frame(width: 68, height: 68) - - VStack(alignment: .leading, spacing: 4) { - Text(partName) - .font(.subheadline) - .fontWeight(.semibold) - Text(carModel) - .font(.subheadline) - .foregroundColor(.textGray1) - } - Spacer() - - VStack(alignment: .leading) { - Text("수량 부족") - .font(.system(size: 14, weight: .semibold)) - .fontWeight(.regular) - .foregroundColor(.StatusRed) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.StatusRedBg) - .cornerRadius(14) - - Text("현재수량: \(currentCount)개") - .font(.caption) - .foregroundColor(.textGray1) - - Text("최소수량: \(minCount)개") - .font(.caption) - .foregroundColor(.textGray1) - } - } - .padding(12) - .background(Color.white) - .cornerRadius(12) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) - } -} - -#Preview { - InventoryView() -} diff --git a/StockMate/StockMate/app/feature/inventory/viewmodel/InventoryViewModel.swift b/StockMate/StockMate/app/feature/inventory/viewmodel/InventoryViewModel.swift new file mode 100644 index 0000000..6467147 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/viewmodel/InventoryViewModel.swift @@ -0,0 +1,274 @@ +// +// InventoryViewModel.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import SwiftUI + +@MainActor +final class InventoryViewModel: ObservableObject { + // ===== 기본 상태 ===== + @Published var inventoryItems: [InventoryItem] = [] + @Published var message: String = "" + @Published var shouldGoToLogin: Bool = false + + // ====== 필터 상태 ====== + @Published var selectedCategories: [String] = [] + @Published var selectedTrims: [String] = [] + @Published var selectedModels: [String] = [] + + + // ===== 전체 재고 페이지네이션 ===== + @Published var currentPage = 0 + @Published var hasMore = true + + // ====== 부족 재고 페이지네이션 ====== + @Published var underLimitItems: [InventoryItem] = [] + @Published var underLimitPage = 0 + @Published var underLimitHasMore = true + + // ====== 검색 관련 ====== + @Published var searchResults: [InventoryItem] = [] + @Published var searchPage = 0 + @Published var searchHasMore = true + @Published var isSearching = false + + // ====== 공통 상태 ====== + @Published var isLoading = false + + private let repo: InventoryRepositoryProtocol + + init(repo: InventoryRepositoryProtocol = InventoryRepositoryImpl()) { + self.repo = repo + } + + // MARK: - 전체 재고 로드 + func loadInventoryList(reset: Bool = false, size: Int = 20) async { + guard !isLoading else { return } + isLoading = true + isSearching = false // 검색 모드 해제 + + if reset { + currentPage = 0 + inventoryItems.removeAll() + hasMore = true + } + + let result = await repo.getInventoryList( + page: currentPage, + size: size, + categoryNames: selectedCategories, + trims: selectedTrims, + models: selectedModels + ) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + inventoryItems = data.content + } else { + inventoryItems.append(contentsOf: data.content) + } + hasMore = currentPage + 1 < data.totalPages + currentPage += 1 + } else { + message = apiResp.message + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + isLoading = false + } + + + func resetAndLoad() async { + await loadInventoryList(reset: true) + } + + // MARK: - 부족 재고 로드 + func loadUnderLimitList(reset: Bool = false, size: Int = 10) async { +// guard !isLoading, underLimitHasMore else { return } + guard !isLoading, (underLimitHasMore || reset) else { return } + isLoading = true + + if reset { + underLimitPage = 0 + underLimitItems.removeAll() + underLimitHasMore = true + } + + // 여기서 under-limit API 호출 + let result = await repo.getUnderLimitList( + categoryName: selectedCategories.first, // 필터 적용 시 사용 + page: underLimitPage, + size: size + ) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + underLimitItems = data.content + } else { + underLimitItems.append(contentsOf: data.content) + } + underLimitHasMore = underLimitPage + 1 < data.totalPages + underLimitPage += 1 + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + isLoading = false + } + + // MARK: - 이름 검색 + func searchByName(name: String, reset: Bool = false, size: Int = 20) async { + guard !isLoading else { return } + isLoading = true + isSearching = true + + if reset { + searchPage = 0 + searchResults.removeAll() + searchHasMore = true + } + + let result = await repo.findByName(name: name, page: searchPage, size: size) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + searchResults = data.content + } else { + searchResults.append(contentsOf: data.content) + } + searchHasMore = searchPage + 1 < data.totalPages + searchPage += 1 + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + + isLoading = false + } + + // MARK: - 검색 결과에서 로컬 필터링 + var filteredSearchResults: [InventoryItem] { + searchResults.filter { item in + // 카테고리 필터 + if !selectedCategories.isEmpty && !selectedCategories.contains(item.categoryName) { + return false + } + // 트림 필터 + if !selectedTrims.isEmpty && !selectedTrims.contains(item.trim) { + return false + } + // 모델 필터 + if !selectedModels.isEmpty && !selectedModels.contains(item.model) { + return false + } + return true + } + } + + // MARK: - 필터 토글 (검색모드 해제 + 전체 재로드) + func toggleCategory(_ name: String) { + if selectedCategories.contains(name) { + selectedCategories.removeAll { $0 == name } + } else { + selectedCategories.append(name) + } + // ✅ 검색 중일 때는 로컬 필터링만 다시 계산 + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } + +// isSearching = false +// Task { await resetAndLoad() } + } + + func toggleTrim(_ trim: String) { + if selectedTrims.contains(trim) { + selectedTrims.removeAll { $0 == trim } + } else { + selectedTrims.append(trim) + } + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } +// isSearching = false +// Task { await resetAndLoad() } + } + + func toggleModel(_ model: String) { + if selectedModels.contains(model) { + selectedModels.removeAll { $0 == model } + } else { + selectedModels.append(model) + } + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } +// isSearching = false +// Task { await resetAndLoad() } + } + + // MARK: - 필터 초기화 + func resetFilters(with searchText: String) { + // 1. 필터 관련 선택 초기화 + selectedCategories.removeAll() + selectedTrims.removeAll() + selectedModels.removeAll() + + // 2. 검색어가 없을 경우 → 전체 재고 다시 + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + isSearching = false + searchResults.removeAll() + // searchPage = 0 + Task { await loadInventoryList(reset: true) } + } + // 3. 검색어가 있는 경우 → 해당 검색어로 전체 결과 다시 검색 + else { + isSearching = true + //searchPage = 0 + searchResults.removeAll() +// Task { await searchByName(name: searchText, reset: true) } + Task { + await searchByName( + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + reset: true + ) + } + } + } + + // MARK: - ✅ 무한 스크롤 로드 + func loadMore(searchText: String) async { + if isSearching { + guard !searchText.trimmingCharacters(in: .whitespaces).isEmpty else { return } + await searchByName(name: searchText) + } else { + await loadInventoryList() + } + } + +} diff --git a/StockMate/StockMate/app/navigation/MainTabView.swift b/StockMate/StockMate/app/navigation/MainTabView.swift index ed4fcef..0b42bed 100644 --- a/StockMate/StockMate/app/navigation/MainTabView.swift +++ b/StockMate/StockMate/app/navigation/MainTabView.swift @@ -9,17 +9,17 @@ import SwiftUI struct MainTabView: View { @State private var selectedTab = 0 + @State private var tabTappedTrigger = false var body: some View { VStack(spacing: 0) { // 메인 화면 ZStack { switch selectedTab { - case 0: NavigationStack{ HomeView() -// UserInfoView() - } + case 0: NavigationStack{ HomeView() } case 1: NavigationStack{ OrderView() } - case 2: NavigationStack{ InventoryView() } + case 2: + NavigationStack { InventoryView(selectedTab: $selectedTab, tabTappedTrigger: $tabTappedTrigger) } case 3: NavigationStack{ ContentView() } default: NavigationStack{ HomeView() } } @@ -44,9 +44,17 @@ struct MainTabView: View { func tabButton(index: Int, icon: String, text: String) -> some View { let isSelected = selectedTab == index return Button { - withAnimation(.easeInOut) { // 탭 전환 애니메이션. 없애도 됨 - selectedTab = index + if selectedTab == index { + // ✅ 같은 탭 다시 누르면 트리거 토글 + tabTappedTrigger.toggle() + } else { + withAnimation(.easeInOut) { + selectedTab = index + } } +// withAnimation(.easeInOut) { // 탭 전환 애니메이션. 없애도 됨 +// selectedTab = index +// } } label: { VStack(spacing: 6) { Image(icon)