From 484984b26322ee4c245bade20d100036dcd0476b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Sun, 2 Nov 2025 15:20:26 +0900 Subject: [PATCH 01/22] =?UTF-8?q?[FEAT]=20=EC=9E=85=EC=B6=9C=EA=B3=A0=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/dashboard/data/HistoryApi.swift | 73 ++++++++ .../data/HistoryRepositoryImpl.swift | 16 ++ .../domain/HistoryRepositoryProtocol.swift | 13 ++ .../dashboard/ui/InOutHistoryView.swift | 167 ++++++++++++++++++ .../dashboard/ui/ReleaseDetailView.swift | 156 ++++++++++++++++ .../viewmodel/HistoryViewModel.swift | 62 +++++++ .../feature/inventory/ui/InventoryView.swift | 2 +- .../app/feature/orders/ui/OrderListView.swift | 3 - 8 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift new file mode 100644 index 0000000..1690fa7 --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -0,0 +1,73 @@ +// +// HistoryApi.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +// MARK: - 입출고 히스토리 데이터 구조 +struct HistoryPageData: Decodable { + let totalElements: Int + let totalPages: Int + let currentPage: Int + let pageSize: Int + let content: [HistoryItem] + let last: Bool +} + +struct HistoryItem: Decodable, Identifiable { + let id: Int + let memberId: Int + let orderId: Int? + let orderNumber: String? + let message: String + let status: String + let type: String + let createdAt: String + let updatedAt: String + let userInfo: HistoryUserInfo? + let items: [HistoryPart] +} + +struct HistoryUserInfo: Decodable { + let id: Int + let memberId: Int + let email: String + let owner: String + let address: String + let storeName: String + let businessNumber: String + let role: String + let verified: String + let latitude: Double + let longitude: Double +} + +struct HistoryPart: 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 amount: Int + let code: String + let location: String + let cost: Int + let historyQuantity: Int +} +// MARK: - API +enum HistoryApi { + // ✅ 가맹점별 입출고 히스토리 조회 + static func getInOutHistory(page: Int = 0, size: Int = 20) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/information/order-history/my?page=\(page)&size=\(size)" + return ApiClient.shared.request(url, method: .get) + } +} diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift new file mode 100644 index 0000000..694de1a --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift @@ -0,0 +1,16 @@ +// +// HistoryRepositoryImpl.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +final class HistoryRepositoryImpl: HistoryRepositoryProtocol { + func getInOutHistory(page: Int, size: Int) async -> AppResult> { + let request = HistoryApi.getInOutHistory(page: page, size: size) + return await safeApi(request, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift b/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift new file mode 100644 index 0000000..0816b5d --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift @@ -0,0 +1,13 @@ +// +// HistoryRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +protocol HistoryRepositoryProtocol { + func getInOutHistory(page: Int, size: Int) async -> AppResult> +} diff --git a/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift b/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift new file mode 100644 index 0000000..43be7ab --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift @@ -0,0 +1,167 @@ +// +// InOutHistoryView.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import SwiftUI + +struct InOutHistoryView: View { + @StateObject private var viewModel = HistoryViewModel() + + var body: some View { + VStack { + if viewModel.isLoading { + ProgressView("불러오는 중...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + } else if viewModel.histories.isEmpty { + Text("입출고 내역이 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // ✅ 날짜별로 그룹화 (최신순) + let groupedHistories = Dictionary(grouping: viewModel.histories) { history in + history.createdAt.split(separator: "T").first.map(String.init) ?? "" + } + .sorted { $0.key > $1.key } // 최신순 정렬 + + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + ForEach(groupedHistories, id: \.key) { date, histories in + VStack(alignment: .leading, spacing: 12) { + // ✅ 날짜 헤더 + Text(formatDate(String(date))) + .font(.headline) + .padding(.leading, 25) + .padding(.top) + + // ✅ 해당 날짜의 히스토리 카드들 + ForEach(histories) { history in + InOutHistoryCard(history: history) + } + } + } + } + .padding(.bottom) + } + .padding(.top) + } + } + .background(Color.Light) + .navigationTitle("입출고 내역") + .task { + await viewModel.fetchInOutHistory() + } + } + + func formatDate(_ dateString: String) -> String { + // yyyy-MM-dd → yyyy년 MM월 dd일 + let comps = dateString.split(separator: "-") + guard comps.count == 3 else { return dateString } + return "\(comps[0])년 \(comps[1])월 \(comps[2])일" + } +} + + +struct InOutHistoryCard: View { + let history: HistoryItem + + var body: some View { + HStack(spacing: 10) { + if let firstItem = history.items.first { + HStack(alignment: .center, spacing: 12) { + AsyncImage(url: URL(string: firstItem.image)) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(firstItem.korName) + .font(.headline) + .lineLimit(1) + if history.items.count > 1 { + Text("외 \(history.items.count - 1)개 품목") + .font(.caption) + .foregroundColor(.gray) + } + } + } + } + + // 입출고 상태 + Text(statusText(history.status)) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(statusBgColor(history.status)) + .foregroundColor(statusTextColor(history.status)) + .cornerRadius(8) + + if history.status == "RECEIVED", let orderId = history.orderId { + NavigationLink( + destination: OrderDetailView(orderId: orderId, orderViewModel: OrderViewModel()) + ) { + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + + } + .buttonStyle(.plain) + } else { + NavigationLink(destination: ReleaseDetailView(history: history)) { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding(.horizontal) + } + + func formatDate(_ iso: String) -> String { + String(iso.prefix(10)).replacingOccurrences(of: "-", with: ".") + } + + func statusText(_ status: String) -> String { + switch status { + case "RECEIVED": return "입고 완료" + case "RELEASED": return "출고 완료" + default: return status + } + } + + func statusTextColor(_ status: String) -> Color { + switch status { + case "RELEASED": return .StatusGreen + case "RECEIVED": return .StatusPurple + default: return .gray + } + } + + func statusBgColor(_ status: String) -> Color { + switch status { + case "RELEASED": return .StatusGreenBg + case "RECEIVED": return .StatusPurpleBg + default: return Color.gray.opacity(0.15) + } + } +} + +#Preview { + InOutHistoryView() +} diff --git a/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift new file mode 100644 index 0000000..9d4e522 --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift @@ -0,0 +1,156 @@ +// +// ReleaseDetailView.swift +// StockMate +// +// Created by Admin on 11/2/25. +// + +import SwiftUI + +struct ReleaseDetailView: View { + let history: HistoryItem + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // ✅ 부품 리스트 + VStack(alignment: .leading, spacing: 7) { + Text("출고 품목 (\(history.items.count)개)") + .font(.system(size: 17, weight: .semibold)) + .padding(.leading) + + // ✅ 처리일자 포맷팅 + Text("처리일자: \(formattedDate3(history.createdAt))") + .font(.system(size: 15)) + .foregroundColor(Color.textGray1) + .padding(.leading) + + ForEach(history.items) { part in + ReleasePartCard(part: part) + } + } + } + .padding(.vertical) + } + .background(Color.Light) + .navigationTitle("출고 상세") + .navigationBarTitleDisplayMode(.inline) + } +// func formatDate(_ iso: String) -> String { +// String(iso.prefix(10)).replacingOccurrences(of: "-", with: ".") +// } +} + +struct ReleasePartCard: View { + let part: HistoryPart + + var body: some View { + VStack(alignment: .leading, spacing: 5){ + // 상단 카테고리 + Text(part.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: part.image)) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(part.korName) + .font(.system(size: 14, weight: .bold)) + .lineLimit(1) + + Text("\(part.model) / \(part.trim) / \(part.price)원 / \(part.amount)개") + .font(.system(size: 12)) + .foregroundColor(.gray) + + Text("\(part.price * part.amount)원") + .font(.system(size: 12)) + .foregroundColor(.black) + } + + Spacer() + + + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding(.horizontal) + } +} + +#Preview { + ReleaseDetailView(history: HistoryItem( + id: 1, + memberId: 1, + orderId: 2, + orderNumber: "ORD-1234", + message: "출고 완료", + status: "RELEASED", + type: "RELEASE", + createdAt: "2025-11-01T12:30:00", + updatedAt: "2025-11-01T12:40:00", + userInfo: nil, + items: [ + HistoryPart(id: 1, name: "partA", price: 1000, image: "", trim: "basic", model: "A1", category: 1, korName: "부품A", engName: "PartA", categoryName: "카테고리A", amount: 5, code: "P001", location: "A-01", cost: 500, historyQuantity: 3) + ] + )) +} + +func formattedDate3(_ timestamp: String) -> String { + // 가능한 입력 포맷들 (서버별 변형 대응) + let inputFormats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + ] + + let trimmed = timestamp.trimmingCharacters(in: .whitespacesAndNewlines) + let parser = DateFormatter() + parser.locale = Locale(identifier: "en_US_POSIX") + parser.timeZone = TimeZone(abbreviation: "UTC") + + var date: Date? = nil + + for format in inputFormats { + parser.dateFormat = format + if let parsed = parser.date(from: trimmed) { + date = parsed + break + } + } + + // ISO8601 fallback + if date == nil { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + date = iso.date(from: trimmed) ?? ISO8601DateFormatter().date(from: trimmed) + } + + guard let finalDate = date else { + return timestamp // 파싱 실패 시 원본 반환 + } + + let output = DateFormatter() + output.locale = Locale(identifier: "ko_KR") + output.timeZone = TimeZone.current + output.dateFormat = "yyyy.MM.dd HH:mm:ss" + + return output.string(from: finalDate) +} + diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift new file mode 100644 index 0000000..28c8eee --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -0,0 +1,62 @@ +// +// HistoryViewModel.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +@MainActor +final class HistoryViewModel: ObservableObject { + @Published var histories: [HistoryItem] = [] + @Published var isLoading = false + @Published var errorMessage: String? = nil // ✅ 추가 + @Published var currentPage = 0 + @Published var totalPages = 1 + + private let repository: HistoryRepositoryProtocol + + init(repository: HistoryRepositoryProtocol = HistoryRepositoryImpl()) { + self.repository = repository + } + + /// ✅ 입출고 히스토리 불러오기 + func fetchInOutHistory(page: Int = 0, size: Int = 20) async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + + let result = await repository.getInOutHistory(page: page, size: size) // ✅ 오타도 수정됨 (getInout → getInOut) + switch result { + case .success(let response): + if let data = response.data { + if page == 0 { + histories = data.content + } else { + histories.append(contentsOf: data.content) + } + currentPage = data.currentPage + totalPages = data.totalPages + errorMessage = nil + } else { + errorMessage = "데이터가 없습니다." + } + case .failure(let error): + errorMessage = error.localizedDescription + print("❌ 입출고 히스토리 조회 실패:", error) + } + } + + /// ✅ 다음 페이지 로드 (무한 스크롤 등) + func loadMoreIfNeeded(currentItem item: HistoryItem?) async { + guard let item = item else { return } + let thresholdIndex = histories.index(histories.endIndex, offsetBy: -5) + if histories.firstIndex(where: { $0.id == item.id }) == thresholdIndex { + if currentPage + 1 < totalPages { + await fetchInOutHistory(page: currentPage + 1) + } + } + } +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift index 9641e2e..702a971 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift @@ -124,7 +124,7 @@ struct InventoryView: View { struct GridMenuView: View { let menuItems = [ ("재고 조회", true, "InvStock", AnyView(InventorySearchView())), - ("입출고 히스토리", false, "InvTrans", AnyView(IncomingScanView())), + ("입출고 히스토리", false, "InvTrans", AnyView(InOutHistoryView())), ("입고 처리", false, "InvIncoming", AnyView(IncomingScanView())), ("사용 처리", true, "InvUse", AnyView(OutgoingScanView())), ] diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift index a6e9aa5..7cd04b1 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift @@ -27,9 +27,6 @@ struct OrderListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // 날짜별로 그룹화 (최신순) -// let groupedOrders = Dictionary(grouping: orderViewModel.orders) { order in -// order.createdAt.split(separator: "T").first ?? "" -// } let groupedOrders = Dictionary(grouping: orderViewModel.orders) { order in order.createdAt.split(separator: "T").first.map(String.init) ?? "" } From ab2d4fab07d202f952c18dbe8752c72efd690c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Sun, 2 Nov 2025 16:52:35 +0900 Subject: [PATCH 02/22] =?UTF-8?q?[FIX]=20=EC=A3=BC=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20enum=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=A7=A4=ED=95=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/feature/auth/ui/LoginView.swift | 6 +- .../feature/cart/ui/DeliveryStatusView.swift | 2 +- .../dashboard/ui/InOutHistoryView.swift | 4 +- .../dashboard/ui/ReleaseDetailView.swift | 4 +- .../inventory/ui/OutgoingScanView.swift | 34 +++++- .../app/feature/orders/data/OrderApi.swift | 8 -- .../feature/orders/ui/OrderDetailView.swift | 100 +++++++++++------- .../app/feature/parts/data/PartApi.swift | 8 +- .../flag.imageset/Contents.json | 12 +++ .../Assets.xcassets/flag.imageset/flag.svg | 3 + 10 files changed, 121 insertions(+), 60 deletions(-) create mode 100644 StockMate/StockMate/resources/Assets.xcassets/flag.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/flag.imageset/flag.svg diff --git a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift index 27236a7..72b16ad 100644 --- a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift @@ -119,9 +119,9 @@ struct LoginView: View { // MARK: - 유효성 검사 함수 private func isValidForm() -> Bool { - emailError = isValidEmail(authViewModel.email) ? nil : "이메일 형식을 확인해주세요" - pwError = authViewModel.password.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" - return emailError == nil && pwError == nil +// emailError = isValidEmail(authViewModel.email) ? nil : "이메일 형식을 확인해주세요" +// pwError = authViewModel.password.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" +// return emailError == nil && pwError == nil return true } diff --git a/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift b/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift index 2825687..6a9f189 100644 --- a/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift +++ b/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift @@ -16,8 +16,8 @@ struct DeliveryStep { struct DeliveryStatusView: View { let steps: [DeliveryStep] = [ DeliveryStep(title: "결제완료", iconName: "check"), - DeliveryStep(title: "승인대기중", iconName: "hourglass"), DeliveryStep(title: "상품준비중", iconName: "uploadprogress"), + DeliveryStep(title: "배송시작", iconName: "flag"), DeliveryStep(title: "배송중", iconName: "rocket"), DeliveryStep(title: "배송완료", iconName: "pindrop") ] diff --git a/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift b/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift index 43be7ab..12998e9 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift @@ -86,7 +86,7 @@ struct InOutHistoryCard: View { VStack(alignment: .leading, spacing: 4) { Text(firstItem.korName) - .font(.headline) + .font(.system(size: 15)) .lineLimit(1) if history.items.count > 1 { Text("외 \(history.items.count - 1)개 품목") @@ -97,6 +97,8 @@ struct InOutHistoryCard: View { } } + Spacer() + // 입출고 상태 Text(statusText(history.status)) .font(.caption) diff --git a/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift index 9d4e522..af44590 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift @@ -69,11 +69,11 @@ struct ReleasePartCard: View { .font(.system(size: 14, weight: .bold)) .lineLimit(1) - Text("\(part.model) / \(part.trim) / \(part.price)원 / \(part.amount)개") + Text("\(part.model) / \(part.trim) / \(part.price)원 / \(part.historyQuantity)개") .font(.system(size: 12)) .foregroundColor(.gray) - Text("\(part.price * part.amount)원") + Text("\(part.price * part.historyQuantity)원") .font(.system(size: 12)) .foregroundColor(.black) } diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index 506c0fa..12a7502 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -92,12 +92,43 @@ struct OutgoingScanView: View { } // ✅ 스캔된 코드로 출고 API 호출 +// private func handleScannedCode(_ code: String) async { +// await MainActor.run { +// partViewModel.isLoading = true +// } +// +// let request = [ReleaseItemRequest(partCode: code, quantity: 1)] // 기본 1개로 설정 +// let result = await partViewModel.releaseParts(items: request) +// +// await MainActor.run { +// partViewModel.isLoading = false +// switch result { +// case .success(let message): +// alertMessage = message +// case .failure(let error): +// alertMessage = error.message +// } +// showAlert = true +// } +// } + // ✅ 스캔된 코드로 출고 API 호출 private func handleScannedCode(_ code: String) async { await MainActor.run { partViewModel.isLoading = true } - let request = [ReleaseItemRequest(partCode: code, quantity: 1)] // 기본 1개로 설정 + // ✅ 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) + guard let partId = Int(code) else { + await MainActor.run { + partViewModel.isLoading = false + alertMessage = "잘못된 QR 코드입니다. (숫자형 ID가 아닙니다)" + showAlert = true + } + return + } + + // ✅ 요청 생성 및 API 호출 + let request = [ReleaseItemRequest(partId: partId, quantity: 1)] // 기본 1개 사용 let result = await partViewModel.releaseParts(items: request) await MainActor.run { @@ -111,6 +142,7 @@ struct OutgoingScanView: View { showAlert = true } } + } #Preview { diff --git a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift index ef3d651..211e3a5 100644 --- a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift +++ b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift @@ -103,14 +103,6 @@ struct OrderItems: Encodable { let amount: Int } -// Response -//struct OrderCreateResponseData: Decodable { -// let orderId: Int -// let orderNumber: String -// let totalPrice: Int -// let orderStatus: String -//} - struct OrderCreateResponseData: Decodable { let orderId: Int let orderNumber: String diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift index 0e4c5de..4ab38d1 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift @@ -203,8 +203,7 @@ struct OrderDetailView: View { // 오른쪽 버튼: 주문 상태에 따라 변경 if order.orderStatus == "ORDER_COMPLETED" || - order.orderStatus == "PAY_COMPLETED" || - order.orderStatus == "PENDING_APPROVAL" { + order.orderStatus == "PAY_COMPLETED" { // "주문취소" → 주문완료/결제완료/승인대기 Button(action: { Task { @@ -220,8 +219,7 @@ struct OrderDetailView: View { .cornerRadius(10) } - } else if order.orderStatus == "PENDING_RECEIVING" || - order.orderStatus == "DELIVERED" { + } else if order.orderStatus == "SHIPPING" { // "입고 하기" → 입고대기/배송완료 Button(action: { // TODO: 입고 처리 버튼 @@ -319,22 +317,30 @@ func formatDateOrDash(_ isoDate: String?) -> String { } func deliveryStep(for status: String) -> Int { - //6 -> 전체 회색 - //4 -> 전체 파란색 + // 6 -> 전체 회색 + // 4 -> 전체 파란색 switch status { case "ORDER_COMPLETED": return 0 // 주문 완료 - case "PAY_COMPLETED": return 0 // 결제 완료 - case "PENDING_APPROVAL": return 1 // 승인 대기 + + // 결제 후 결과에 따라 결제 실패 or 완료 case "FAILED": return 6 // 결제 실패 + case "PAY_COMPLETED": return 0 // 결제 완료 + + // 결제 완료 상태에서 지점이 주문 취소 + case "CANCELLED": return 6 // 주문 취소 + + // 본사에서 "결제 완료"에 대해서 주문을 반려 or 승인 + case "REJECTED": return 6 // 주문 반려 + case "APPROVAL_ORDER": return 1 // 주문 승인 + + // 창고관리자가 "주문 승인"에 대해서 송장(인보이스)를 뽑으면 출고 대기 case "PENDING_SHIPPING": return 2 // 출고 대기 + + // 창고관리자가 QR을 스캔하여 출고처리 하면 배송중 case "SHIPPING": return 3 // 배송중 - case "PENDING_RECEIVING": return 4 // 입고 대기 - case "REJECTED": return 6 // 승인 반려 - case "DELIVERED": return 4 // 배송 완료 - case "RECEIVED": return 4 // 입고 완료 - case "REFUNDED": return 6 // 환불 완료 - case "REFUND_REJECTED": return 6 // 환불 반려 - case "CANCELLED": return 6 // 주문 취소 + + // 지점에서 QR을 스캔하여 입고 완료 처리 + case "RECEIVED": return 4 // 입고 완료 default: return 6 } } @@ -345,21 +351,31 @@ func formatDate(_ isoDate: String) -> String { return "\(comps[0])년 \(comps[1])월 \(comps[2])일" } +// 0: 초록, 1: 빨강, 2: 주황, 3: 노랑, 4: 파랑, 5: 보라 + func statusText(_ status: String) -> String { switch status { case "ORDER_COMPLETED": return "주문 완료" // 주문 완료 - case "PAY_COMPLETED": return "결제 완료" // 결제 완료 - case "PENDING_APPROVAL": return "승인 대기" // 승인대기 + + // 결제 후 결과에 따라 결제 실패 or 완료 case "FAILED": return "결제 실패" // 결제 실패 + case "PAY_COMPLETED": return "결제 완료" // 결제 완료 + + // 결제 완료 상태에서 지점이 주문 취소 + case "CANCELLED": return "주문 취소" // 주문 취소 + + // 본사에서 "결제 완료"에 대해서 주문을 반려 or 승인 + case "REJECTED": return "결제 실패" // 주문 반려 + case "APPROVAL_ORDER": return "출고 대기" // 주문 승인 + + // 창고관리자가 "주문 승인"에 대해서 송장(인보이스)를 뽑으면 출고 대기 case "PENDING_SHIPPING": return "출고 대기" // 출고 대기 + + // 창고관리자가 QR을 스캔하여 출고처리 하면 배송중 case "SHIPPING": return "배송중" // 배송중 - case "PENDING_RECEIVING": return "배송 완료" // 입고대기 - case "REJECTED": return "승인 반려" // 이론상 출고 반려 - case "DELIVERED": return "배송 완료" // 배송 완료 + + // 지점에서 QR을 스캔하여 입고 완료 처리 case "RECEIVED": return "입고 완료" // 입고 완료 - case "REFUNDED": return "환불 완료" // 환불 완료 - case "REFUND_REJECTED": return "환불 반려" // 환불 반려 - case "CANCELLED": return "주문 취소" // 주문 취소 default: return "알 수 없음" } } @@ -367,18 +383,19 @@ func statusText(_ status: String) -> String { func statusColor(_ status: String) -> Color { switch status { case "ORDER_COMPLETED": return .StatusGreen - case "PAY_COMPLETED": return .StatusGreen - case "PENDING_APPROVAL": return .Warning + case "FAILED": return .Danger - case "PENDING_SHIPPING": return .InvUse - case "SHIPPING": return .Transfer - case "PENDING_RECEIVING": return .Secondary + case "PAY_COMPLETED": return .StatusGreen + + case "CANCELLED": return .Danger + case "REJECTED": return .Danger - case "DELIVERED": return .Secondary + case "APPROVAL_ORDER": return .Warning + + case "PENDING_SHIPPING": return .InvUse + case "SHIPPING": return .Secondary + case "RECEIVED": return .StatusPurple - case "REFUNDED": return .Gray - case "REFUND_REJECTED": return .Gray - case "CANCELLED": return .Gray default: return .gray.opacity(0.6) } } @@ -386,18 +403,19 @@ func statusColor(_ status: String) -> Color { func statusBdColor(_ status: String) -> Color { switch status { case "ORDER_COMPLETED": return .StatusGreenBg - case "PAY_COMPLETED": return .StatusGreenBg - case "PENDING_APPROVAL": return .WarningBg + case "FAILED": return .DangerBg - case "PENDING_SHIPPING": return .InvUseBg - case "SHIPPING": return .TransferBg - case "PENDING_RECEIVING": return .LightBlue04 + case "PAY_COMPLETED": return .StatusGreenBg + + case "CANCELLED": return .DangerBg + case "REJECTED": return .DangerBg - case "DELIVERED": return .LightBlue04 + case "APPROVAL_ORDER": return .WarningBg + + case "PENDING_SHIPPING": return .InvUseBg + case "SHIPPING": return .LightBlue04 + case "RECEIVED": return .StatusPurpleBg - case "REFUNDED": return Color(hex: "#EEEEEF") - case "REFUND_REJECTED": return Color(hex: "#EEEEEF") - case "CANCELLED": return Color(hex: "#EEEEEF") default: return .gray.opacity(0.6) } } diff --git a/StockMate/StockMate/app/feature/parts/data/PartApi.swift b/StockMate/StockMate/app/feature/parts/data/PartApi.swift index 9c5070d..67e835e 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartApi.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -9,9 +9,9 @@ import Foundation import Alamofire -// ✅ 요청 모델 +// ✅ 요청 모델 (partCode → partId 로 변경) struct ReleaseItemRequest: Encodable { - let partCode: String + let partId: Int let quantity: Int } @@ -20,7 +20,9 @@ enum PartApi { static func releaseParts(items: [ReleaseItemRequest]) -> DataRequest { let url = ApiClient.baseURL + "api/v1/store/release" let body: [String: Any] = [ - "items": items.map { ["partCode": $0.partCode, "quantity": $0.quantity] } +// "items": items.map { ["partId": $0.partId, "quantity": $0.quantity] } + "items": items.map { ["partId": $0.partId, "quantity": $0.quantity] } + ] return ApiClient.shared.request( url, diff --git a/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/Contents.json new file mode 100644 index 0000000..02f005f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "flag.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/flag.svg b/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/flag.svg new file mode 100644 index 0000000..3c142bb --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/flag.imageset/flag.svg @@ -0,0 +1,3 @@ + + + From f8330b06fd7eef7ce1dd287cc11752765c259a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Sun, 2 Nov 2025 21:04:48 +0900 Subject: [PATCH 03/22] =?UTF-8?q?[FEAT]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A7=80=EC=B6=9C=ED=98=84=ED=99=A9=20=EC=A4=91=EA=B0=84=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/BarChartView.swift | 96 +++++++++++++++++++ .../app/feature/auth/ui/HomeView.swift | 47 +++++---- .../app/feature/payment/data/PaymentApi.swift | 13 +++ .../payment/data/PaymentRepositoryImpl.swift | 16 ++++ .../domain/PaymentRepositoryProtocol.swift | 3 + .../viewmodel/DashboardViewModel.swift | 48 ++++++++++ 6 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 StockMate/StockMate/app/core/components/BarChartView.swift create mode 100644 StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift diff --git a/StockMate/StockMate/app/core/components/BarChartView.swift b/StockMate/StockMate/app/core/components/BarChartView.swift new file mode 100644 index 0000000..96184e3 --- /dev/null +++ b/StockMate/StockMate/app/core/components/BarChartView.swift @@ -0,0 +1,96 @@ +// +// BarChartView.swift +// StockMate +// +// Created by Admin on 11/2/25. +// + +import SwiftUI + +struct BarChartView: View { + let values: [CGFloat] // 각 월별 비율값 (0~1) + let labels: [String] // 예: ["06", "07", "08", "09", "10"] + let amounts: [Int] // 예: [230000, 250000, 310000, 280000, 400000] + @Binding var selectedMonth: String? + + var body: some View { + // ✅ 최신월이 오른쪽에 오도록 역순 정렬 + let reversedValues = Array(values.reversed()) + let reversedLabels = Array(labels.reversed()) + let reversedAmounts = Array(amounts.reversed()) + + // ✅ "07" → "7월" 형식 변환 + let displayLabels = reversedLabels.map { label in + if let monthInt = Int(label) { + return "\(monthInt)월" + } else { + return label + } + } + + // ✅ 기본 선택: 최신월 + let defaultMonth = displayLabels.first ?? "" + let activeMonth = selectedMonth ?? defaultMonth + + VStack(alignment: .leading, spacing: 14) { + // 제목 + Text("월간 지출 현황") + .font(.headline) + .foregroundColor(.black) + .padding(.horizontal, 4) + + // ✅ 막대 그래프 + GeometryReader { geometry in + let totalWidth = geometry.size.width + let barCount = CGFloat(reversedValues.count) + let barWidth: CGFloat = 28 + let spacing = max((totalWidth - (barWidth * barCount)) / (barCount + 1), 6) + + HStack(alignment: .bottom, spacing: spacing) { + ForEach(reversedValues.indices, id: \.self) { i in + VStack { + RoundedRectangle(cornerRadius: 6) + .fill(activeMonth == displayLabels[i] ? Color.Primary : Color.LightBlue04) + .frame(width: barWidth, height: 150 * reversedValues[i]) + .onTapGesture { + // 선택/해제 처리 + selectedMonth = (selectedMonth == displayLabels[i]) ? nil : displayLabels[i] + } + + Text(displayLabels[i]) + .font(.caption2) + .foregroundColor(.black) + .padding(.top, 4) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 180) + .padding(.vertical) + + Divider() + + // ✅ 하단 "n월 지출금액 ooo원" 표시 + if let index = displayLabels.firstIndex(of: activeMonth) { + HStack { + Text("\(displayLabels[index]) 지출금액") + .font(.subheadline) + .foregroundColor(.gray) + Spacer() + Text("\(reversedAmounts[index].formatted())원") + .font(.headline) + .foregroundColor(Color.Primary) + } + .padding(.top, 6) + } + } + .frame(maxWidth: .infinity) + // ✅ 초기 로드 시 최신월 자동 선택 + .onAppear { + if selectedMonth == nil { + selectedMonth = defaultMonth + } + } + } +} diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index 1094e52..2290203 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -12,7 +12,12 @@ struct HomeView: View { @EnvironmentObject var authViewModel: AuthViewModel @StateObject private var userViewModel = UserViewModel() @StateObject private var inventoryViewModel = InventoryViewModel() + @StateObject private var dashboardViewModel = DashboardViewModel() + @State private var selectedMonth: String? = nil // ✅ 추가 +// @State private var selectedMonthIndex: Int? = nil + + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 15) { @@ -88,19 +93,29 @@ struct HomeView: View { .cornerRadius(16) .padding(.horizontal) - - // 막대그래프 섹션 VStack(alignment: .leading, spacing: 8) { - Text("지출 현황") + Text("원간 지출 현황") .font(.headline) .padding(4) - BarChartView() - .frame(height: 150) -// .padding() + if dashboardViewModel.isLoading { + ProgressView("데이터 불러오는 중...") + .frame(height: 150) + } else if dashboardViewModel.monthlySpendings.isEmpty { + Text("최근 지출 내역이 없습니다.") + .foregroundColor(.gray) + .frame(height: 150) + } else { + BarChartView( + values: dashboardViewModel.spendingRatios, + labels: dashboardViewModel.monthLabels, + amounts: dashboardViewModel.monthlySpendings.map { $0.totalAmount }, selectedMonth: $selectedMonth + ) + .frame(height: 200) .background(Color.white) - .shadow(color: .gray.opacity(0.1), radius: 4) + .cornerRadius(16) + } } .padding() .background(Color.white) @@ -113,6 +128,7 @@ struct HomeView: View { .task { // 카테고리 데이터 로드 await inventoryViewModel.loadLackCountByCategory() + await dashboardViewModel.fetchMonthlySpending() // ✅ 추가 } .onAppear { Task { await userViewModel.loadUserInfo() } @@ -234,23 +250,6 @@ struct DonutChartView: View { } } -// MARK: - 막대그래프 (더미) -struct BarChartView: View { - let values: [CGFloat] = [0.89, 0.5, 0.9, 0.3, 0.7] - let colors: [Color] = [ - .LightBlue04, .Primary, .LightBlue04, .LightBlue04, .LightBlue04 - ] - var body: some View { - HStack(alignment: .bottom, spacing: 33) { - ForEach(0.. DataRequest { + let url = ApiClient.baseURL + "api/v1/payment/monthly-spending" + return ApiClient.shared.request(url, method: .get) + } } diff --git a/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift b/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift index 625b887..7117d5b 100644 --- a/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift @@ -45,4 +45,20 @@ final class PaymentRepositoryImpl: PaymentRepositoryProtocol { return .failure(error) } } + + // ✅ 최근 5개월 소비 내역 조회 + func fetchMonthlySpending() async -> AppResult<[MonthlySpending]> { + let request = PaymentApi.getMonthlySpending() + let result = await safeApi(request, decodeTo: ApiResponse<[MonthlySpending]>.self) + switch result { + case .success(let response): + if let data = response.data { + return .success(data) + } else { + return .failure(.init(code: response.status, message: response.message, underlying: nil)) + } + case .failure(let error): + return .failure(error) + } + } } diff --git a/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift b/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift index 150ab26..fff5558 100644 --- a/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift @@ -11,4 +11,7 @@ import Alamofire protocol PaymentRepositoryProtocol { func fetchDepositAmount() async -> AppResult func chargeDeposit(amount: Int) async -> AppResult + + // ✅ 최근 5개월 소비 내역 조회 + func fetchMonthlySpending() async -> AppResult<[MonthlySpending]> } diff --git a/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift b/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift new file mode 100644 index 0000000..0741759 --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift @@ -0,0 +1,48 @@ +// +// DashboardViewModel.swift +// StockMate +// +// Created by Admin on 11/2/25. +// + +import Foundation + +@MainActor +final class DashboardViewModel: ObservableObject { + private let repo: PaymentRepositoryProtocol = PaymentRepositoryImpl() + + @Published var monthlySpendings: [MonthlySpending] = [] + @Published var isLoading = false + + /// ✅ 최근 5개월 소비 내역 조회 + func fetchMonthlySpending() async { + isLoading = true + let result = await repo.fetchMonthlySpending() + isLoading = false + + switch result { + case .success(let data): + monthlySpendings = data + case .failure(let err): + print("❌ 월별 소비 내역 조회 실패:", err.message) + } + } + + /// ✅ 막대그래프 비율 계산 + var spendingRatios: [CGFloat] { + guard let max = monthlySpendings.map({ $0.totalAmount }).max(), max > 0 else { return [] } + return monthlySpendings.map { CGFloat($0.totalAmount) / CGFloat(max) } + } + + /// ✅ 월 라벨 (예: "10월", "11월") + var monthLabels: [String] { + monthlySpendings.map { month in + // "2025-10" → "10월" + if month.month.count >= 7 { + return String(month.month.suffix(2)) + "월" + } else { + return month.month + } + } + } +} From 79ca35ecd8a65c3b68dc282df1200350bf0053bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 12:18:02 +0900 Subject: [PATCH 04/22] =?UTF-8?q?[FEAT]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=EC=B0=A8=ED=8A=B8=20=EC=A4=91=EA=B0=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/BarChartView.swift | 27 +++--- .../app/core/components/ChartEx.swift | 69 ++++++++++++++ .../app/core/components/DonutChartView.swift | 90 +++++++++++++++++++ .../app/feature/auth/ui/HomeView.swift | 74 ++++++++------- .../feature/dashboard/data/HistoryApi.swift | 2 + .../app/feature/payment/data/PaymentApi.swift | 13 +++ .../payment/data/PaymentRepositoryImpl.swift | 17 ++++ .../domain/PaymentRepositoryProtocol.swift | 4 + .../viewmodel/DashboardViewModel.swift | 23 ++++- StockMate/StockMate/resources/Color.swift | 4 +- 10 files changed, 270 insertions(+), 53 deletions(-) create mode 100644 StockMate/StockMate/app/core/components/ChartEx.swift create mode 100644 StockMate/StockMate/app/core/components/DonutChartView.swift diff --git a/StockMate/StockMate/app/core/components/BarChartView.swift b/StockMate/StockMate/app/core/components/BarChartView.swift index 96184e3..a06f145 100644 --- a/StockMate/StockMate/app/core/components/BarChartView.swift +++ b/StockMate/StockMate/app/core/components/BarChartView.swift @@ -29,18 +29,13 @@ struct BarChartView: View { } // ✅ 기본 선택: 최신월 - let defaultMonth = displayLabels.first ?? "" + let defaultMonth = displayLabels.last ?? "" let activeMonth = selectedMonth ?? defaultMonth VStack(alignment: .leading, spacing: 14) { - // 제목 - Text("월간 지출 현황") - .font(.headline) - .foregroundColor(.black) - .padding(.horizontal, 4) - // ✅ 막대 그래프 GeometryReader { geometry in + let chartHeight = geometry.size.height * 0.85 // 상하 여백 고려 let totalWidth = geometry.size.width let barCount = CGFloat(reversedValues.count) let barWidth: CGFloat = 28 @@ -49,11 +44,11 @@ struct BarChartView: View { HStack(alignment: .bottom, spacing: spacing) { ForEach(reversedValues.indices, id: \.self) { i in VStack { - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 8) .fill(activeMonth == displayLabels[i] ? Color.Primary : Color.LightBlue04) - .frame(width: barWidth, height: 150 * reversedValues[i]) + // ✅ 막대 높이를 geometry 기준으로 조정 + .frame(width: barWidth, height: chartHeight * reversedValues[i]) .onTapGesture { - // 선택/해제 처리 selectedMonth = (selectedMonth == displayLabels[i]) ? nil : displayLabels[i] } @@ -66,23 +61,23 @@ struct BarChartView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } - .frame(height: 180) - .padding(.vertical) + .frame(height: 140) // ← 전체 그래프 영역 높이 확장 + .padding(.vertical, 8) Divider() // ✅ 하단 "n월 지출금액 ooo원" 표시 if let index = displayLabels.firstIndex(of: activeMonth) { HStack { - Text("\(displayLabels[index]) 지출금액") - .font(.subheadline) - .foregroundColor(.gray) + Text("\(displayLabels[index]) 지출 현황") + .font(.system(size: 17, weight: .medium)) Spacer() Text("\(reversedAmounts[index].formatted())원") - .font(.headline) + .font(.system(size: 18, weight: .bold)) .foregroundColor(Color.Primary) } .padding(.top, 6) + .padding(.horizontal,4) } } .frame(maxWidth: .infinity) diff --git a/StockMate/StockMate/app/core/components/ChartEx.swift b/StockMate/StockMate/app/core/components/ChartEx.swift new file mode 100644 index 0000000..e8195c2 --- /dev/null +++ b/StockMate/StockMate/app/core/components/ChartEx.swift @@ -0,0 +1,69 @@ +// +// ChartEx.swift +// StockMate +// +// Created by Admin on 11/3/25. +// + +//import SwiftUI +//import Charts +// +//struct ChartEx: View { +// let data: [CategorySpending] +// +// init(data: [CategorySpending]) { +// self.data = data +// } +// +// private var total: Double { +// Double(data.map { $0.totalAmount }.reduce(0, +)) +// } +// +// var body: some View { +// VStack(alignment: .leading, spacing: 16) { +// Text("카테고리별 지출 현황") +// .font(.headline) +// .foregroundStyle(.primary) +// +// Chart { +// ForEach(data, id: \.categoryName) { item in +// SectorMark( +// angle: .value("지출 비율", item.totalAmount), +// innerRadius: .ratio(0.65), +// angularInset: 2.0 +// ) +// .foregroundStyle(by: .value("카테고리", item.categoryName)) +// .cornerRadius(8.0) +// .annotation(position: .overlay) { +// if total > 0 { +// let percent = (Double(item.totalAmount) / total) * 100 +// Text("\(Int(percent))%") +// .font(.caption) +// .foregroundStyle(.white) +// } +// } +// } +// } +// .chartLegend(position: .bottom, alignment: .leading) +// .frame(height: 260) +// +// HStack { +// Text("총합: \(Int(total).formatted())원") +// .font(.subheadline) +// .foregroundColor(.gray) +// Spacer() +// } +// } +// .padding() +// } +//} +// +//#Preview { +// ChartEx(data: [ +// CategorySpending(categoryName: "식비", totalAmount: 120000), +// CategorySpending(categoryName: "교통", totalAmount: 85000), +// CategorySpending(categoryName: "문화", totalAmount: 45000), +// CategorySpending(categoryName: "쇼핑", totalAmount: 155000), +// CategorySpending(categoryName: "기타", totalAmount: 30000) +// ]) +//} diff --git a/StockMate/StockMate/app/core/components/DonutChartView.swift b/StockMate/StockMate/app/core/components/DonutChartView.swift new file mode 100644 index 0000000..826a000 --- /dev/null +++ b/StockMate/StockMate/app/core/components/DonutChartView.swift @@ -0,0 +1,90 @@ +// +// DonutChartView.swift +// StockMate +// +// Created by Admin on 11/3/25. +// + +import SwiftUI +import Charts + +struct DonutChartView: View { + let data: [CategorySpending] + + var total: Double { + Double(data.map { $0.totalAmount }.reduce(0, +)) + } + + // ✅ 각 항목별 비율 계산 + var percentages: [Double] { + data.map { total == 0 ? 0 : (Double($0.totalAmount) / total * 100) } + } + + var colors: [Color] = [ + Color.Hstatus1, + Color.Hstatus2, + Color.Hstatus3, + Color.Hstatus4, + Color.Hstatus5 + ] + + var body: some View { + HStack(alignment: .center, spacing: 24) { + // ✅ 도넛 차트 + if total == 0 { + Text("데이터 없음") + .foregroundColor(.gray) + } else { + Chart { + ForEach(Array(data.enumerated()), id: \.offset) { index, item in + SectorMark( + angle: .value("지출", item.totalAmount), + innerRadius: .ratio(0.56), + angularInset: 1.9 + ) + .foregroundStyle(colors[index % colors.count]) + .cornerRadius(8.0) + // ✅ 도넛 안쪽에 비율 표시 + .annotation(position: .overlay) { + let percentage = percentages[index] + Text("\(percentage, specifier: "%.1f")%") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.black) + .offset(y: -2) + } + } + } + .frame(height: 200) + .chartLegend(.hidden) // 기본 범례 숨김 + } + + // ✅ 오른쪽 커스텀 범례 + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(data.enumerated()), id: \.offset) { index, item in + let percentage = percentages[index] + HStack(spacing: 8) { + Circle() + .fill(colors[index % colors.count]) + .frame(width: 10, height: 10) + Text(item.categoryName) + .font(.system(size: 12, weight: .medium)) + .frame(width: 70, alignment: .leading) + Text("\(percentage, specifier: "%.1f")%") + .font(.system(size: 12)) + .foregroundColor(.black) + } + } + } + } + } +} + +#Preview { + DonutChartView(data: [ + CategorySpending(categoryName: "전기/램프", totalAmount: 450000), + CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), + CategorySpending(categoryName: "하체/바디", totalAmount: 150000), + CategorySpending(categoryName: "내장/외장", totalAmount: 100000), + CategorySpending(categoryName: "기타소모품", totalAmount: 50000) + ]) +} diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index 2290203..2e641a2 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -12,7 +12,8 @@ struct HomeView: View { @EnvironmentObject var authViewModel: AuthViewModel @StateObject private var userViewModel = UserViewModel() @StateObject private var inventoryViewModel = InventoryViewModel() - @StateObject private var dashboardViewModel = DashboardViewModel() + @EnvironmentObject var dashboardViewModel: DashboardViewModel +// @StateObject private var dashboardViewModel = DashboardViewModel() @State private var selectedMonth: String? = nil // ✅ 추가 // @State private var selectedMonthIndex: Int? = nil @@ -70,20 +71,29 @@ struct HomeView: View { lackStockSection - // 도넛 차트 섹션 VStack(alignment: .leading, spacing: 18) { Text("지난달 카테고리 별 지출") .font(.headline) .padding(4) - HStack{ - DonutChartView() - .frame(height: 130) - .padding() - .background(Color.white) - .cornerRadius(16) - .shadow(color: .gray.opacity(0.1), radius: 4) + HStack { + if dashboardViewModel.isLoading { + ProgressView("불러오는 중...") + .frame(height: 130) +// } else if dashboardViewModel.categorySpendings.isEmpty { +// Text("최근 지출 내역이 없습니다.") +// .foregroundColor(.gray) +// .frame(height: 150) + } else { + DonutChartView(data: dashboardViewModel.categorySpendings) + + .frame(height: 180) + .padding() + .background(Color.white) + .cornerRadius(16) + .shadow(color: .gray.opacity(0.1), radius: 4) + } Spacer() } @@ -92,10 +102,11 @@ struct HomeView: View { .background(Color.white) .cornerRadius(16) .padding(.horizontal) + // 막대그래프 섹션 VStack(alignment: .leading, spacing: 8) { - Text("원간 지출 현황") + Text("월간 지출 현황") .font(.headline) .padding(4) @@ -112,7 +123,7 @@ struct HomeView: View { labels: dashboardViewModel.monthLabels, amounts: dashboardViewModel.monthlySpendings.map { $0.totalAmount }, selectedMonth: $selectedMonth ) - .frame(height: 200) + .frame(height: 220) .background(Color.white) .cornerRadius(16) } @@ -122,13 +133,14 @@ struct HomeView: View { .cornerRadius(16) .padding(.horizontal) } - .padding(.vertical) + .padding(.vertical,5) } .background(Color.Light) .task { // 카테고리 데이터 로드 await inventoryViewModel.loadLackCountByCategory() await dashboardViewModel.fetchMonthlySpending() // ✅ 추가 + await dashboardViewModel.fetchCategorySpending() // ✅ 추가 } .onAppear { Task { await userViewModel.loadUserInfo() } @@ -228,29 +240,23 @@ struct StatusItem: View { } } -// MARK: - 도넛 차트 (더미) -struct DonutChartView: View { - var body: some View { - ZStack { - Circle() - .trim(from: 0, to: 0.521) - .stroke(Color.black, lineWidth: 40) - Circle() - .trim(from: 0.521, to: 0.749) - .stroke(Color(hex: "#7DBBFF"), lineWidth: 40) - Circle() - .trim(from: 0.749, to: 0.888) - .stroke(Color(hex: "#71DD8C"), lineWidth: 40) - Circle() - .trim(from: 0.888, to: 1) - .stroke(Color(hex: "#A0BCE8"), lineWidth: 40) - } - .rotationEffect(.degrees(-89.9)) - .padding() - } -} +//#Preview { +// HomeView() +//} + #Preview { - HomeView() + let dashboardVM = DashboardViewModel() + dashboardVM.categorySpendings = [ + CategorySpending(categoryName: "전기/램프", totalAmount: 450000), + CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), + CategorySpending(categoryName: "하체/바디", totalAmount: 150000), + CategorySpending(categoryName: "내장/외장", totalAmount: 100000), + CategorySpending(categoryName: "기타소모품", totalAmount: 50000) + ] + + return HomeView() + .environmentObject(AuthViewModel()) + .environmentObject(dashboardVM) // ✅ 이제 진짜 연결됨! } diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift index 1690fa7..b0e93c1 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -63,6 +63,8 @@ struct HistoryPart: Decodable, Identifiable { let cost: Int let historyQuantity: Int } + + // MARK: - API enum HistoryApi { // ✅ 가맹점별 입출고 히스토리 조회 diff --git a/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift b/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift index b9a1791..c2db3ef 100644 --- a/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift +++ b/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift @@ -23,6 +23,12 @@ struct MonthlySpending: Decodable, Identifiable { let totalAmount: Int } +struct CategorySpending: Decodable, Identifiable { + var id: String { categoryName } + let categoryName: String + let totalAmount: Int +} + enum PaymentApi { // 예치금 조회 @@ -50,4 +56,11 @@ enum PaymentApi { let url = ApiClient.baseURL + "api/v1/payment/monthly-spending" return ApiClient.shared.request(url, method: .get) } + + + // ✅ 지난달 카테고리별 지출금액 조회 + static func getCategorySpending() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/category-spend" + return ApiClient.shared.request(url, method: .get) + } } diff --git a/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift b/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift index 7117d5b..6024082 100644 --- a/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift @@ -61,4 +61,21 @@ final class PaymentRepositoryImpl: PaymentRepositoryProtocol { return .failure(error) } } + + // ✅ 지난달 카테고리별 지출 + func fetchCategorySpending() async -> AppResult<[CategorySpending]> { + let request = PaymentApi.getCategorySpending() + let result = await safeApi(request, decodeTo: ApiResponse<[CategorySpending]>.self) + switch result { + case .success(let response): + if let data = response.data { + return .success(data) + } else { + return .failure(.init(code: response.status, message: response.message, underlying: nil)) + } + case .failure(let error): + return .failure(error) + } + } + } diff --git a/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift b/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift index fff5558..fe22926 100644 --- a/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift @@ -14,4 +14,8 @@ protocol PaymentRepositoryProtocol { // ✅ 최근 5개월 소비 내역 조회 func fetchMonthlySpending() async -> AppResult<[MonthlySpending]> + + // 지난달 카테고리별 지출 + func fetchCategorySpending() async -> AppResult<[CategorySpending]> + } diff --git a/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift b/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift index 0741759..8ef7136 100644 --- a/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift +++ b/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift @@ -12,6 +12,8 @@ final class DashboardViewModel: ObservableObject { private let repo: PaymentRepositoryProtocol = PaymentRepositoryImpl() @Published var monthlySpendings: [MonthlySpending] = [] + @Published var categorySpendings: [CategorySpending] = [] + @Published var isLoading = false /// ✅ 최근 5개월 소비 내역 조회 @@ -28,6 +30,20 @@ final class DashboardViewModel: ObservableObject { } } + // ✅ 지난달 카테고리별 지출 금액 조회 + func fetchCategorySpending() async { + isLoading = true + let result = await repo.fetchCategorySpending() + isLoading = false + + switch result { + case .success(let data): + categorySpendings = data + case .failure(let err): + print("❌ 카테고리별 지출 금액 조회 실패:", err.message) + } + } + /// ✅ 막대그래프 비율 계산 var spendingRatios: [CGFloat] { guard let max = monthlySpendings.map({ $0.totalAmount }).max(), max > 0 else { return [] } @@ -39,7 +55,12 @@ final class DashboardViewModel: ObservableObject { monthlySpendings.map { month in // "2025-10" → "10월" if month.month.count >= 7 { - return String(month.month.suffix(2)) + "월" + let suffix = String(month.month.suffix(2)) + if let monthInt = Int(suffix) { + return "\(monthInt)월" + } else { + return suffix + "월" + } } else { return month.month } diff --git a/StockMate/StockMate/resources/Color.swift b/StockMate/StockMate/resources/Color.swift index 94172ee..44ecf7b 100644 --- a/StockMate/StockMate/resources/Color.swift +++ b/StockMate/StockMate/resources/Color.swift @@ -81,8 +81,8 @@ extension Color { //DFF6FC // Home Status - static let Hstatus1 = Color(hex: "#DFF6FC") - static let Hstatus2 = Color(hex: "#DBDFF3") + static let Hstatus1 = Color(hex: "#08C2EB") + static let Hstatus2 = Color(hex: "#1F40AE") static let Hstatus3 = Color(hex: "#EB5032") static let Hstatus4 = Color(hex: "#8DDB55") static let Hstatus5 = Color(hex: "#8892A2") From 40ef1dba27e418845734c333eea23a159e88d206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 12:34:24 +0900 Subject: [PATCH 05/22] =?UTF-8?q?[FEAT]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/ChartEx.swift | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 StockMate/StockMate/app/core/components/ChartEx.swift diff --git a/StockMate/StockMate/app/core/components/ChartEx.swift b/StockMate/StockMate/app/core/components/ChartEx.swift deleted file mode 100644 index e8195c2..0000000 --- a/StockMate/StockMate/app/core/components/ChartEx.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ChartEx.swift -// StockMate -// -// Created by Admin on 11/3/25. -// - -//import SwiftUI -//import Charts -// -//struct ChartEx: View { -// let data: [CategorySpending] -// -// init(data: [CategorySpending]) { -// self.data = data -// } -// -// private var total: Double { -// Double(data.map { $0.totalAmount }.reduce(0, +)) -// } -// -// var body: some View { -// VStack(alignment: .leading, spacing: 16) { -// Text("카테고리별 지출 현황") -// .font(.headline) -// .foregroundStyle(.primary) -// -// Chart { -// ForEach(data, id: \.categoryName) { item in -// SectorMark( -// angle: .value("지출 비율", item.totalAmount), -// innerRadius: .ratio(0.65), -// angularInset: 2.0 -// ) -// .foregroundStyle(by: .value("카테고리", item.categoryName)) -// .cornerRadius(8.0) -// .annotation(position: .overlay) { -// if total > 0 { -// let percent = (Double(item.totalAmount) / total) * 100 -// Text("\(Int(percent))%") -// .font(.caption) -// .foregroundStyle(.white) -// } -// } -// } -// } -// .chartLegend(position: .bottom, alignment: .leading) -// .frame(height: 260) -// -// HStack { -// Text("총합: \(Int(total).formatted())원") -// .font(.subheadline) -// .foregroundColor(.gray) -// Spacer() -// } -// } -// .padding() -// } -//} -// -//#Preview { -// ChartEx(data: [ -// CategorySpending(categoryName: "식비", totalAmount: 120000), -// CategorySpending(categoryName: "교통", totalAmount: 85000), -// CategorySpending(categoryName: "문화", totalAmount: 45000), -// CategorySpending(categoryName: "쇼핑", totalAmount: 155000), -// CategorySpending(categoryName: "기타", totalAmount: 30000) -// ]) -//} From 72deb00aed3b47b6b1b992e0113414803f2dcd00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 12:35:07 +0900 Subject: [PATCH 06/22] =?UTF-8?q?[FEAT]=20=ED=99=88=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/DonutChartView.swift | 33 +++++++++--- .../app/feature/auth/ui/HomeView.swift | 50 +++++++++---------- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/StockMate/StockMate/app/core/components/DonutChartView.swift b/StockMate/StockMate/app/core/components/DonutChartView.swift index 826a000..2dd37c6 100644 --- a/StockMate/StockMate/app/core/components/DonutChartView.swift +++ b/StockMate/StockMate/app/core/components/DonutChartView.swift @@ -28,6 +28,15 @@ struct DonutChartView: View { Color.Hstatus5 ] + var gradients: [AngularGradient] = [ + AngularGradient(gradient: Gradient(colors: [.pink, .orange]), center: .center), + AngularGradient(gradient: Gradient(colors: [.blue, .teal]), center: .center), + AngularGradient(gradient: Gradient(colors: [.green, .mint]), center: .center), + AngularGradient(gradient: Gradient(colors: [.purple, .indigo]), center: .center), + AngularGradient(gradient: Gradient(colors: [.gray, .black]), center: .center) + ] + + var body: some View { HStack(alignment: .center, spacing: 24) { // ✅ 도넛 차트 @@ -39,35 +48,45 @@ struct DonutChartView: View { ForEach(Array(data.enumerated()), id: \.offset) { index, item in SectorMark( angle: .value("지출", item.totalAmount), - innerRadius: .ratio(0.56), + innerRadius: .ratio(0.49), angularInset: 1.9 ) - .foregroundStyle(colors[index % colors.count]) + .foregroundStyle( + LinearGradient( + gradient: Gradient(colors: [ + colors[index % colors.count], + colors[index % colors.count].opacity(0.5) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) +// .foregroundStyle(colors[index % colors.count]) .cornerRadius(8.0) // ✅ 도넛 안쪽에 비율 표시 .annotation(position: .overlay) { let percentage = percentages[index] Text("\(percentage, specifier: "%.1f")%") - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10, weight: .light)) .foregroundColor(.black) .offset(y: -2) } } } - .frame(height: 200) + .frame(height: 150) .chartLegend(.hidden) // 기본 범례 숨김 } // ✅ 오른쪽 커스텀 범례 - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 18) { ForEach(Array(data.enumerated()), id: \.offset) { index, item in let percentage = percentages[index] - HStack(spacing: 8) { + HStack(spacing: 7) { Circle() .fill(colors[index % colors.count]) .frame(width: 10, height: 10) Text(item.categoryName) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .light)) .frame(width: 70, alignment: .leading) Text("\(percentage, specifier: "%.1f")%") .font(.system(size: 12)) diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index 2e641a2..4a0b4d6 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -12,11 +12,10 @@ struct HomeView: View { @EnvironmentObject var authViewModel: AuthViewModel @StateObject private var userViewModel = UserViewModel() @StateObject private var inventoryViewModel = InventoryViewModel() - @EnvironmentObject var dashboardViewModel: DashboardViewModel -// @StateObject private var dashboardViewModel = DashboardViewModel() +// @EnvironmentObject var dashboardViewModel: DashboardViewModel //preview 용 + @StateObject private var dashboardViewModel = DashboardViewModel() @State private var selectedMonth: String? = nil // ✅ 추가 -// @State private var selectedMonthIndex: Int? = nil var body: some View { @@ -81,18 +80,15 @@ struct HomeView: View { if dashboardViewModel.isLoading { ProgressView("불러오는 중...") .frame(height: 130) -// } else if dashboardViewModel.categorySpendings.isEmpty { -// Text("최근 지출 내역이 없습니다.") -// .foregroundColor(.gray) -// .frame(height: 150) + } else if dashboardViewModel.categorySpendings.isEmpty { + Text("최근 지출 내역이 없습니다.") + .foregroundColor(.gray) + .frame(height: 150) } else { DonutChartView(data: dashboardViewModel.categorySpendings) - - .frame(height: 180) - .padding() - .background(Color.white) - .cornerRadius(16) - .shadow(color: .gray.opacity(0.1), radius: 4) + .frame(height: 155) + .background(Color.white) + .cornerRadius(16) } Spacer() @@ -246,17 +242,17 @@ struct StatusItem: View { // HomeView() //} -#Preview { - let dashboardVM = DashboardViewModel() - dashboardVM.categorySpendings = [ - CategorySpending(categoryName: "전기/램프", totalAmount: 450000), - CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), - CategorySpending(categoryName: "하체/바디", totalAmount: 150000), - CategorySpending(categoryName: "내장/외장", totalAmount: 100000), - CategorySpending(categoryName: "기타소모품", totalAmount: 50000) - ] - - return HomeView() - .environmentObject(AuthViewModel()) - .environmentObject(dashboardVM) // ✅ 이제 진짜 연결됨! -} +//#Preview { +// let dashboardVM = DashboardViewModel() +// dashboardVM.categorySpendings = [ +// CategorySpending(categoryName: "전기/램프", totalAmount: 450000), +// CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), +// CategorySpending(categoryName: "하체/바디", totalAmount: 150000), +// CategorySpending(categoryName: "내장/외장", totalAmount: 100000), +// CategorySpending(categoryName: "기타소모품", totalAmount: 50000) +// ] +// +// return HomeView() +// .environmentObject(AuthViewModel()) +// .environmentObject(dashboardVM) // ✅ 이제 진짜 연결됨! +//} From 92a5c275091a494b6848877067795ddc2a77b0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 16:39:54 +0900 Subject: [PATCH 07/22] =?UTF-8?q?[FEAT]=20=EC=97=90=EC=B9=98=EA=B8=88=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- StockMate/.DS_Store | Bin 6148 -> 6148 bytes .../app/feature/auth/ui/HomeView.swift | 2 +- .../feature/dashboard/data/HistoryApi.swift | 39 +++++ .../data/HistoryRepositoryImpl.swift | 6 + .../domain/HistoryRepositoryProtocol.swift | 3 + .../dashboard/ui/DepositHistoryView.swift | 135 ++++++++++++++++++ .../viewmodel/HistoryViewModel.swift | 45 ++++++ .../app/feature/orders/data/OrderApi.swift | 34 +++++ .../app/feature/user/ui/ProfileView.swift | 36 ++--- .../bag.imageset/Contents.json | 21 +++ .../Assets.xcassets/bag.imageset/bag.svg | 3 + .../exchange.imageset/Contents.json | 12 ++ .../exchange.imageset/exchange.svg | 9 ++ .../lock.imageset/Contents.json | 12 ++ .../Assets.xcassets/lock.imageset/lock.svg | 7 + .../logout.imageset/Contents.json | 12 ++ .../logout.imageset/logout.svg | 5 + .../notification 1.imageset/Contents.json | 12 ++ .../notification 1.imageset/notification.svg | 5 + .../receipt.imageset/Contents.json | 12 ++ .../receipt.imageset/receipt.svg | 8 ++ .../user.imageset/Contents.json | 12 ++ .../Assets.xcassets/user.imageset/user.svg | 4 + 23 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift create mode 100644 StockMate/StockMate/resources/Assets.xcassets/bag.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/bag.imageset/bag.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/exchange.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/lock.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/lock.imageset/lock.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/logout.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/logout.imageset/logout.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/notification.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/receipt.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/user.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/user.imageset/user.svg diff --git a/StockMate/.DS_Store b/StockMate/.DS_Store index 9b2b289a6cc938aeb964150a839318a8b9520a06..96d21b81f6d7b95cdc550b0bdfe354e75ac4fec1 100644 GIT binary patch delta 69 zcmZoMXffCj$I7(m{Nw~yGmeK#QZ)RcjynQ5I+IVaav=+7@fTzm1}Ep|7BGMStH8#> MPmG({IsWnk0B6e=a{vGU delta 69 zcmZoMXffCj$I6sxGdY3PjN?)1%B>yY#~pzjoyn(IxsV04_zN-&gOl@f3m8Cv<-x|n MPmG({IsWnk0EhY+EC2ui diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index 4a0b4d6..ddd982c 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -81,7 +81,7 @@ struct HomeView: View { ProgressView("불러오는 중...") .frame(height: 130) } else if dashboardViewModel.categorySpendings.isEmpty { - Text("최근 지출 내역이 없습니다.") + Text("지난달 지출 내역이 없습니다.") .foregroundColor(.gray) .frame(height: 150) } else { diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift index b0e93c1..8882e6b 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -65,6 +65,39 @@ struct HistoryPart: Decodable, Identifiable { } +// MARK: - 예치금 거래내역 데이터 구조 +struct PaymentTransactionPageData: Decodable { + let content: [PaymentTransactionItem] + let page: Int + let size: Int + let totalElements: Int + let totalPages: Int + let hasNext: Bool + let hasPrevious: Bool + let last: Bool + let first: Bool +} + +//struct PaymentTransactionItem: Decodable, Identifiable { +// var id: UUID { UUID() } // 서버에서 id 제공 안하므로 로컬에서 생성 +// let transactionType: String // "CHARGE" or "PAY" +// let transactionTime: String +// let totalAmount: Int +// let orderId: Int +// let balance: Int +//} + +struct PaymentTransactionItem: Decodable, Identifiable { + var id: UUID { UUID() } // 서버에서 id 제공 안하므로 로컬 생성 + let transactionType: String // "CHARGE" or "PAY" + let transactionTime: String? // ✅ null 허용 + let totalAmount: Int + let orderId: Int? // ✅ null 허용 + let balance: Int +} + + + // MARK: - API enum HistoryApi { // ✅ 가맹점별 입출고 히스토리 조회 @@ -72,4 +105,10 @@ enum HistoryApi { let url = ApiClient.baseURL + "api/v1/information/order-history/my?page=\(page)&size=\(size)" return ApiClient.shared.request(url, method: .get) } + + // ✅ 예치금 거래내역 조회 + static func getPaymentTransaction(page: Int = 0, size: Int = 20) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/payment/transaction?page=\(page)&size=\(size)" + return ApiClient.shared.request(url, method: .get) + } } diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift index 694de1a..1c8dfe4 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift @@ -13,4 +13,10 @@ final class HistoryRepositoryImpl: HistoryRepositoryProtocol { let request = HistoryApi.getInOutHistory(page: page, size: size) return await safeApi(request, decodeTo: ApiResponse.self) } + + // ✅ 예치금 거래내역 조회 + func getPaymentTransaction(page: Int, size: Int) async -> AppResult> { + let request = HistoryApi.getPaymentTransaction(page: page, size: size) + return await safeApi(request, decodeTo: ApiResponse.self) + } } diff --git a/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift b/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift index 0816b5d..fddd5cc 100644 --- a/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift @@ -10,4 +10,7 @@ import Alamofire protocol HistoryRepositoryProtocol { func getInOutHistory(page: Int, size: Int) async -> AppResult> + + // ✅ 예치금 거래내역 조회 + func getPaymentTransaction(page: Int, size: Int) async -> AppResult> } diff --git a/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift b/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift new file mode 100644 index 0000000..97f8c6c --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift @@ -0,0 +1,135 @@ +// +// DepositHistoryView.swift +// StockMate +// +// Created by Admin on 11/3/25. +// + +import SwiftUI + +struct DepositHistoryView: View { + @StateObject private var viewModel = HistoryViewModel() + + var body: some View { + VStack { + // 본문 스크롤 + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.transactions) { item in + DepositHistoryRow(item: item) + .onAppear { + Task { + await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) + } + } + } + + if viewModel.isTransactionLoading { + ProgressView() + .padding() + } + } + .padding(.horizontal) + } + .background(Color.Light) + .navigationTitle("예치금 히스토리") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await viewModel.fetchPaymentTransactions() + } + } + .task { + await viewModel.fetchPaymentTransactions() + } + } +} + +// MARK: - 개별 거래 Row +struct DepositHistoryRow: View { + let item: PaymentTransactionItem + + var isCharge: Bool { item.transactionType == "CHARGE" } + + var body: some View { + HStack(alignment: .center, spacing: 13) { + // 아이콘 + Image(isCharge ? "exchange" : "bag") + .frame(width: 64, height: 64) + .foregroundColor(isCharge ? .Primary : .gray) + + VStack(alignment: .leading, spacing: 4) { + Text(isCharge ? "예치금 충전" : "실린더 어셈블리-브레이크 마스터 외 3개") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.black) + + Text(formatDate(item.transactionTime ?? "")) + .font(.caption) + .foregroundColor(.gray) + + Text(formatAmount(item.totalAmount, isCharge: isCharge)) + .font(.subheadline) + .foregroundColor(isCharge ? .Primary : .red) + } + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + // MARK: - Helper + private func formatAmount(_ amount: Int, isCharge: Bool) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + return (isCharge ? "+ " : "- ") + formatted + "원" + } + + private func formatDate(_ dateString: String) -> String { + // 서버에서 "2025.10.29 17:32:39" 형식이면 그대로 반환 + // 혹은 ISO8601이면 변환 필요 + if dateString.contains(".") { + return dateString + } else { + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: dateString) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "yyyy.MM.dd HH:mm:ss" + return displayFormatter.string(from: date) + } + return dateString + } + } +} + +// MARK: - Dummy Preview +#Preview { + VStack(spacing: 16) { + DepositHistoryRow( + item: PaymentTransactionItem( + transactionType: "CHARGE", + transactionTime: "2025-11-03T09:12:45", + totalAmount: 50000, + orderId: nil, + balance: 50000 + ) + ) + DepositHistoryRow( + item: PaymentTransactionItem( + transactionType: "PAY", + transactionTime: "2025-11-03T14:34:35.608713", + totalAmount: 49720, + orderId: 61, + balance: 4954617 + ) + ) + } + .padding() + .background(Color.Light) +} diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift index 28c8eee..e01b262 100644 --- a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -10,12 +10,19 @@ import Alamofire @MainActor final class HistoryViewModel: ObservableObject { + // MARK: - 입출고 히스토리 관련 @Published var histories: [HistoryItem] = [] @Published var isLoading = false @Published var errorMessage: String? = nil // ✅ 추가 @Published var currentPage = 0 @Published var totalPages = 1 + // MARK: - 예치금 거래내역 관련 + @Published var transactions: [PaymentTransactionItem] = [] + @Published var transactionPage = 0 + @Published var transactionTotalPages = 1 + @Published var isTransactionLoading = false + private let repository: HistoryRepositoryProtocol init(repository: HistoryRepositoryProtocol = HistoryRepositoryImpl()) { @@ -59,4 +66,42 @@ final class HistoryViewModel: ObservableObject { } } } + + // MARK: - ✅ 예치금 거래내역 불러오기 + func fetchPaymentTransactions(page: Int = 0, size: Int = 20) async { + guard !isTransactionLoading else { return } + isTransactionLoading = true + defer { isTransactionLoading = false } + + let result = await repository.getPaymentTransaction(page: page, size: size) + switch result { + case .success(let response): + if let data = response.data { + if page == 0 { + transactions = data.content + } else { + transactions.append(contentsOf: data.content) + } + transactionPage = data.page + transactionTotalPages = data.totalPages + errorMessage = nil + } else { + errorMessage = "데이터가 없습니다." + } + case .failure(let error): + errorMessage = error.localizedDescription + print("❌ 예치금 거래내역 조회 실패:", error) + } + } + + // MARK: - ✅ 무한 스크롤 (예치금 내역) + func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { + guard let item = item else { return } + let thresholdIndex = transactions.index(transactions.endIndex, offsetBy: -5) + if transactions.firstIndex(where: { $0.id == item.id }) == thresholdIndex { + if transactionPage + 1 < transactionTotalPages { + await fetchPaymentTransactions(page: transactionPage + 1) + } + } + } } diff --git a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift index 211e3a5..40b4507 100644 --- a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift +++ b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift @@ -116,6 +116,13 @@ struct ReceiveOrderRequest: Encodable { } +struct OrderSummary { + let firstPartName: String + let itemCount: Int + let totalPrice: Int + let createdAt: String +} + // MARK: - API Call @@ -180,4 +187,31 @@ enum OrderApi { encoder: JSONParameterEncoder.default ) } + + // 주문 상세 정보 preview 데이터 가지고 오기 +// static func fetchOrderSummary(orderId: Int) async -> OrderSummary? { +// let result = await OrderRepositoryImpl().fetchOrderDetail(orderId: orderId) +// +// switch result { +// case .success(let order): // order == OrderResponseItem +// guard let first = order.orderItems.first else { return nil } +// return OrderSummary( +// firstPartName: first.partDetail.korName, +// itemCount: order.orderItems.count, +// totalPrice: order.totalPrice, +// createdAt: order.createdAt +// ) +// case .failure: +// return nil +// } +// } + static func fetchOrderSummary(orderId: Int) async -> OrderSummary? { + // 예시용 더미 데이터 + return OrderSummary( + firstPartName: "실린더 어셈블리-브레이크 마스터", + itemCount: 4, + totalPrice: 49720, + createdAt: "2025.11.03 14:34:35" + ) + } } diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift index 92fdf6a..bf330f7 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -38,34 +38,14 @@ struct ProfileView: View { // MARK: - General Section VStack(alignment: .leading, spacing: 12) { - Text("General") - .font(.system(size: 16, weight: .semibold)) - .padding(.leading) - VStack(spacing: 10) { - SettingRow(icon: "person.crop.circle", title: "Edit Profile") - SettingRow(icon: "lock.circle", title: "Change Password") - SettingRow(icon: "bell", title: "Notifications") - SettingRow(icon: "location.circle", title: "배송 현황") - + SettingRow(icon: "user", title: "프로필 수정") + SettingRow(icon: "lock", title: "비밀번호 변경") + SettingRow(icon: "notification", title: "알림") + SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: DepositHistoryView()) +// SettingRow(icon: "credit", title: "예치금 히스토리") SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView()) - } - .padding(3) - .background(Color.Light) - .cornerRadius(12) - .padding(.horizontal) - } - - // MARK: - Preferences Section - VStack(alignment: .leading, spacing: 12) { - Text("Preferences") - .font(.system(size: 16, weight: .semibold)) - .padding(.leading) - - VStack(spacing: 10) { - SettingRow(icon: "shield", title: "Legal and Policies") - SettingRow(icon: "questionmark.circle", title: "Help & Support") - SettingRow(icon: "arrow.right.circle", title: "Logout", iconColor: .red, textColor: .red) + SettingRow(icon: "logout", title: "로그아웃") } .padding(3) .background(Color.Light) @@ -93,7 +73,7 @@ struct SettingRow: View { var body: some View { HStack { - Image(systemName: icon) + Image(icon) .font(.system(size: 18)) .foregroundColor(iconColor) .frame(width: 24) @@ -124,7 +104,7 @@ struct SettingNavigationRow: View { var body: some View { NavigationLink(destination: destination) { HStack { - Image(systemName: icon) + Image(icon) .font(.system(size: 18)) .foregroundColor(iconColor) .frame(width: 24) diff --git a/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/Contents.json new file mode 100644 index 0000000..66aacdc --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bag.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/bag.svg b/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/bag.svg new file mode 100644 index 0000000..efc5703 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/bag.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/Contents.json new file mode 100644 index 0000000..b7cfc19 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "exchange.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/exchange.svg b/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/exchange.svg new file mode 100644 index 0000000..84b5196 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/exchange.imageset/exchange.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/Contents.json new file mode 100644 index 0000000..72cfcb5 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "lock.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/lock.svg b/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/lock.svg new file mode 100644 index 0000000..fe83ff3 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/lock.imageset/lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/Contents.json new file mode 100644 index 0000000..bc4d524 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logout.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/logout.svg b/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/logout.svg new file mode 100644 index 0000000..039492d --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/logout.imageset/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/Contents.json new file mode 100644 index 0000000..5dabea1 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "notification.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/notification.svg b/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/notification.svg new file mode 100644 index 0000000..6ba310d --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notification 1.imageset/notification.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/Contents.json new file mode 100644 index 0000000..59421d0 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "receipt.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/receipt.svg b/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/receipt.svg new file mode 100644 index 0000000..6c77a8c --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/receipt.imageset/receipt.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/user.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/user.imageset/Contents.json new file mode 100644 index 0000000..8ac482c --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/user.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "user.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/user.imageset/user.svg b/StockMate/StockMate/resources/Assets.xcassets/user.imageset/user.svg new file mode 100644 index 0000000..3f51a37 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/user.imageset/user.svg @@ -0,0 +1,4 @@ + + + + From f2246cdb73182a00a639d98dc143c187848fcd1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 17:34:54 +0900 Subject: [PATCH 08/22] =?UTF-8?q?[FEAT]=20=EB=B6=80=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inventory/ui/OutgoingScanView.swift | 20 ------- .../app/feature/parts/data/PartApi.swift | 33 ++++++++++++ .../parts/data/PartRepositoryImpl.swift | 6 +++ .../parts/domain/PartRepositoryProtocol.swift | 1 + .../parts/viewmodel/PartViewModel.swift | 52 ++++++++++++++++++- 5 files changed, 91 insertions(+), 21 deletions(-) diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index 12a7502..62b5526 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -92,26 +92,6 @@ struct OutgoingScanView: View { } // ✅ 스캔된 코드로 출고 API 호출 -// private func handleScannedCode(_ code: String) async { -// await MainActor.run { -// partViewModel.isLoading = true -// } -// -// let request = [ReleaseItemRequest(partCode: code, quantity: 1)] // 기본 1개로 설정 -// let result = await partViewModel.releaseParts(items: request) -// -// await MainActor.run { -// partViewModel.isLoading = false -// switch result { -// case .success(let message): -// alertMessage = message -// case .failure(let error): -// alertMessage = error.message -// } -// showAlert = true -// } -// } - // ✅ 스캔된 코드로 출고 API 호출 private func handleScannedCode(_ code: String) async { await MainActor.run { partViewModel.isLoading = true diff --git a/StockMate/StockMate/app/feature/parts/data/PartApi.swift b/StockMate/StockMate/app/feature/parts/data/PartApi.swift index 67e835e..ede241d 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartApi.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -15,6 +15,25 @@ struct ReleaseItemRequest: Encodable { let quantity: Int } +// ✅ 부품 상세 정보 모델 +struct PartDetailResponse: Decodable { + 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 amount: Int + let code: String + let location: String + let cost: Int +} + + // ✅ API 정의 enum PartApi { static func releaseParts(items: [ReleaseItemRequest]) -> DataRequest { @@ -31,4 +50,18 @@ enum PartApi { encoding: JSONEncoding.default ) } + + // ✅ 부품 상세 조회 API + static func fetchPartDetail(partId: Int) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/parts/detail" + let body: [String: Any] = ["partId": partId] + + return ApiClient.shared.request( + url, + method: .post, + parameters: body, + encoding: JSONEncoding.default + ) + } + } diff --git a/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift index 7ac1c08..8a063cc 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift @@ -13,4 +13,10 @@ final class PartRepositoryImpl: PartRepositoryProtocol { let dataReq = PartApi.releaseParts(items: items) return await safeApi(dataReq, decodeTo: ApiResponse.self) } + + // ✅ 부품 상세 조회 API + func fetchPartDetail(partId: Int) async -> AppResult> { + let dataReq = PartApi.fetchPartDetail(partId: partId) + return await safeApi(dataReq, decodeTo: ApiResponse<[PartDetailResponse]>.self) + } } diff --git a/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift index f3a939e..3e028d3 100644 --- a/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift @@ -9,4 +9,5 @@ import Foundation protocol PartRepositoryProtocol { func releaseParts(items: [ReleaseItemRequest]) async -> AppResult> + func fetchPartDetail(partId: Int) async -> AppResult> } diff --git a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift index fc3fecd..ead6256 100644 --- a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift +++ b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift @@ -14,12 +14,60 @@ final class PartViewModel: ObservableObject { @Published var message: String = "" @Published var shouldGoToLogin = false + + // ✅ 임시 저장 리스트 + @Published var selectedParts: [PartDetailResponse] = [] + @Published var quantities: [Int: Int] = [:] // partId별 수량 관리 + + private let repo: PartRepositoryProtocol init(repo: PartRepositoryProtocol = PartRepositoryImpl()) { self.repo = repo } + // ✅ 부품 상세 조회 (QR 스캔 후) + func fetchPartDetail(partId: Int) async { + isLoading = true + defer { isLoading = false } + + let result = await repo.fetchPartDetail(partId: partId) + switch result { + case .success(let apiResp): + if let detail = apiResp.data?.first { + if !selectedParts.contains(where: { $0.id == detail.id }) { + selectedParts.append(detail) + quantities[detail.id] = 1 + } else { + quantities[detail.id, default: 1] += 1 + } + } + case .failure(let error): + message = error.message + } + } + + func increaseQuantity(for partId: Int) { + quantities[partId, default: 1] += 1 + } + + func decreaseQuantity(for partId: Int) { + quantities[partId] = max(1, (quantities[partId] ?? 1) - 1) + } + + func removePart(partId: Int) { + selectedParts.removeAll { $0.id == partId } + quantities.removeValue(forKey: partId) + } + + func makeReleasePayload() -> [ReleaseItemRequest] { + selectedParts.map { part in + ReleaseItemRequest(partId: part.id, quantity: quantities[part.id] ?? 1) + } + } + + + func releaseParts(items: [ReleaseItemRequest]) async -> AppResult { isLoading = true defer { isLoading = false } @@ -41,4 +89,6 @@ final class PartViewModel: ObservableObject { return .failure(err) } } -} \ No newline at end of file + + +} From c64204b14325a29b5f7531e845b4f4d1a3c3f8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Mon, 3 Nov 2025 21:42:01 +0900 Subject: [PATCH 09/22] =?UTF-8?q?[FEAT]=20=EB=B6=80=ED=92=88=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=9E=84=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/CartCard.swift | 7 - .../feature/inventory/ui/InventoryView.swift | 2 +- .../inventory/ui/OutgoingScanView.swift | 226 ++++-- .../inventory/ui/PartBottomSheetView.swift | 672 ++++++++++++++++++ .../app/feature/parts/data/PartApi.swift | 40 +- .../parts/data/PartRepositoryImpl.swift | 6 +- .../app/feature/parts/data/PartStore.swift | 40 ++ .../parts/domain/PartRepositoryProtocol.swift | 2 +- .../app/feature/parts/ui/QRScannerView.swift | 10 +- .../parts/ui/QRScannerViewController.swift | 25 +- .../parts/viewmodel/PartViewModel.swift | 67 +- .../StockMate/app/navigation/AppNavHost.swift | 3 + .../app/navigation/MainTabView.swift | 7 +- 13 files changed, 988 insertions(+), 119 deletions(-) create mode 100644 StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift create mode 100644 StockMate/StockMate/app/feature/parts/data/PartStore.swift diff --git a/StockMate/StockMate/app/core/components/CartCard.swift b/StockMate/StockMate/app/core/components/CartCard.swift index 1d959d0..772629f 100644 --- a/StockMate/StockMate/app/core/components/CartCard.swift +++ b/StockMate/StockMate/app/core/components/CartCard.swift @@ -47,13 +47,6 @@ struct CartCard: View { .font(.system(size: 13, weight: .semibold)) .foregroundColor(.black) -// Text("\(item.trim) / \(item.model)") -// .font(.system(size: 13)) -// .foregroundColor(.gray) -// -// Text("\(item.price)원") -// .font(.system(size: 13, weight: .semibold)) -// .foregroundColor(.black) } Spacer() diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift index 702a971..70063c4 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift @@ -126,7 +126,7 @@ struct GridMenuView: View { ("재고 조회", true, "InvStock", AnyView(InventorySearchView())), ("입출고 히스토리", false, "InvTrans", AnyView(InOutHistoryView())), ("입고 처리", false, "InvIncoming", AnyView(IncomingScanView())), - ("사용 처리", true, "InvUse", AnyView(OutgoingScanView())), + ("사용 처리", true, "InvUse", AnyView(OutgoingScanView().environmentObject(PartStore()))), ] var body: some View { diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index 62b5526..a6b11cb 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -13,13 +13,23 @@ struct OutgoingScanView: View { @State private var scannedCode: String? = nil @State private var showAlert = false @State private var alertMessage = "" + + // 사용 처리 임시 + @EnvironmentObject var partStore: PartStore + @State private var showBottomSheet = false + + @State private var scannerRestartTrigger = false +// @State private var isPresentingBottomSheet = false + @State private var partDetail: PartDetail? = nil + @StateObject private var partViewModel = PartViewModel() // ✅ ViewModel 추가 var body: some View { ZStack { // ✅ 카메라 미리보기 (QR 스캐너) QRScannerView(scannedCode: $scannedCode) + .id(scannerRestartTrigger) // 👈 다시 렌더링되어 카메라 리셋됨 .ignoresSafeArea() // ✅ 스캔 가이드 및 UI 오버레이 @@ -65,39 +75,85 @@ struct OutgoingScanView: View { // ✅ 로딩 인디케이터 if partViewModel.isLoading { - Color.black.opacity(0.3).ignoresSafeArea() - ProgressView("부품 사용 처리 중...") - .padding() - .background(.ultraThinMaterial) - .cornerRadius(10) - } - } - // ✅ 알림창 - .alert("부품 사용 결과", isPresented: $showAlert) { - Button("확인") { - dismiss() - } - } message: { - Text(alertMessage) - } - // ✅ QR 스캔 이벤트 발생 시 - .onChange(of: scannedCode) { newValue in - guard let code = newValue, !code.isEmpty else { return } - Task { - await handleScannedCode(code) - } + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("부품 조회 중...") + .padding() + .background(.ultraThinMaterial) + .cornerRadius(10) + } +// if partViewModel.isLoading { +// Color.black.opacity(0.3).ignoresSafeArea() +// ProgressView("부품 사용 처리 중...") +// .padding() +// .background(.ultraThinMaterial) +// .cornerRadius(10) +// } } - .navigationTitle("부품 사용 처리") - .navigationBarTitleDisplayMode(.inline) - } + .alert("알림", isPresented: $showAlert) { + Button("확인") {} + } message: { + Text(alertMessage) + } + .onChange(of: scannedCode) { newValue in + guard let code = newValue, !code.isEmpty else { return } + Task { await handleScannedCode(code) } + } + .sheet(isPresented: $showBottomSheet) { + if let part = partDetail { + PartBottomSheetView( + part: part, + onAddPart: { + // ✅ 부품 추가 버튼 액션 + partStore.addPart(part) - // ✅ 스캔된 코드로 출고 API 호출 + // ✅ 상태 초기화 (QR 다시 가능하도록) + resetScanState() + }, + onUseParts: { + // ✅ 사용 처리 버튼 액션 (예: 서버 전송) + let payload = partStore.makeRequestPayload() + print("🚀 사용 처리 API 호출 payload:", payload) + } + ) + .environmentObject(partStore) + } + } + .navigationTitle("부품 사용 처리") + .navigationBarTitleDisplayMode(.inline) +// .sheet(isPresented: $showBottomSheet) { +// PartBottomSheetView() +// .environmentObject(partStore) +// } +// .navigationTitle("부품 사용 처리") +// .navigationBarTitleDisplayMode(.inline) + + //-----// +// // ✅ 알림창 +// .alert("부품 사용 결과", isPresented: $showAlert) { +// Button("확인") { +// dismiss() +// } +// } message: { +// Text(alertMessage) +// } +// // ✅ QR 스캔 이벤트 발생 시 +// .onChange(of: scannedCode) { newValue in +// guard let code = newValue, !code.isEmpty else { return } +// Task { +// await handleScannedCode(code) +// } +// } +// .navigationTitle("부품 사용 처리") +// .navigationBarTitleDisplayMode(.inline) + } + + // ✅ 스캔된 코드로 부품 상세 조회만 수행 (출고 X) private func handleScannedCode(_ code: String) async { await MainActor.run { partViewModel.isLoading = true } - // ✅ 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) + // 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) guard let partId = Int(code) else { await MainActor.run { partViewModel.isLoading = false @@ -107,26 +163,114 @@ struct OutgoingScanView: View { return } - // ✅ 요청 생성 및 API 호출 - let request = [ReleaseItemRequest(partId: partId, quantity: 1)] // 기본 1개 사용 - let result = await partViewModel.releaseParts(items: request) + // ✅ 부품 상세 조회 API 호출 + await partViewModel.fetchPartDetail(partId: partId) + // ✅ 결과 출력 await MainActor.run { partViewModel.isLoading = false - switch result { - case .success(let message): - alertMessage = message - case .failure(let error): - alertMessage = error.message - } - showAlert = true + + guard let response = partViewModel.partDetails.first else { + alertMessage = "부품 정보를 불러오지 못했습니다." + showAlert = true + return + } + + partDetail = PartDetail( + id: response.id, + price: response.price, + image: response.image, + trim: response.trim, + model: response.model, + korName: response.korName, + categoryName: response.categoryName, + quantity: 1 + ) +// // ✅ API 응답을 PartDetail로 변환 +// let newPart = PartDetail( +// id: response.id, +// price: response.price, +// image: response.image, +// trim: response.trim, +// model: response.model, +// korName: response.korName, +// categoryName: response.categoryName, +// quantity: 1 +// ) +// +// // ✅ 리스트에 추가 +// partStore.addPart(newPart) + + // ✅ 바텀 시트 띄우기 + showBottomSheet = true + + //-----/// + +// if let part = partViewModel.partDetails.first { +// print("✅ 부품 상세 조회 성공") +// print("ID:", part.id) +// print("이름:", part.name) +// print("가격:", part.price) +// print("모델:", part.model) +// print("트림:", part.trim) +// print("코드:", part.code) +// alertMessage = "부품 조회 성공: \(part.name)" +// } else { +// print("⚠️ 부품 정보를 불러오지 못함") +// alertMessage = "부품 정보를 불러오지 못했습니다." +// } +// +// showAlert = true } } -} - -#Preview { - NavigationStack { - OutgoingScanView() +// // ✅ 스캔된 코드로 출고 API 호출 +// private func handleScannedCode(_ code: String) async { +// await MainActor.run { +// partViewModel.isLoading = true +// } +// +// // ✅ 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) +// guard let partId = Int(code) else { +// await MainActor.run { +// partViewModel.isLoading = false +// alertMessage = "잘못된 QR 코드입니다. (숫자형 ID가 아닙니다)" +// showAlert = true +// } +// return +// } +// +// // ✅ 요청 생성 및 API 호출 +// let request = [ReleaseItemRequest(partId: partId, quantity: 1)] // 기본 1개 사용 +// let result = await partViewModel.releaseParts(items: request) +// +// await MainActor.run { +// partViewModel.isLoading = false +// switch result { +// case .success(let message): +// alertMessage = message +// case .failure(let error): +// alertMessage = error.message +// } +// showAlert = true +// } +// } + + // ✅ 상태 초기화 (QR 다시 활성화) + private func resetScanState() { + scannedCode = nil + partDetail = nil + showBottomSheet = false + partViewModel.partDetails.removeAll() + + // ✅ 카메라 세션 재시작 + scannerRestartTrigger.toggle() } + } + +//#Preview { +// NavigationStack { +// OutgoingScanView() +// } +//} diff --git a/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift new file mode 100644 index 0000000..827e919 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift @@ -0,0 +1,672 @@ +// +// PartBottomSheetView.swift +// StockMate +// +// Created by Admin on 11/3/25. +// + +import SwiftUI + +struct PartBottomSheetView: View { + @EnvironmentObject var partStore: PartStore + @Environment(\.dismiss) private var dismiss + @StateObject private var partViewModel = PartViewModel() + + let part: PartDetail + let onAddPart: () -> Void + let onUseParts: () -> Void + + @State private var quantity: Int = 1 + @State private var isProcessing = false + @State private var showAlert = false + @State private var alertMessage = "" + + var body: some View { + VStack(spacing: 16) { + + // MARK: - 방금 스캔한 부품 미리보기 (추가 전) + HStack(spacing: 12) { + AsyncImage(url: URL(string: part.image)) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + Color.gray.opacity(0.3) + } + } + .frame(width: 48, height: 48) + .clipped() + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 4) { + Text(part.korName) + .font(.subheadline) + Text("\(part.model) / \(part.trim)") + .font(.caption) + .foregroundColor(.gray) + Text("\(part.price) 원") + .font(.subheadline) + } + + Spacer() + + // ✅ 수정된 수량 조절 구역 + HStack(spacing: 8) { + Button(action: { + if quantity > 1 { quantity -= 1 } + }) { + Image(systemName: "minus.circle.fill") + .font(.title2) + .foregroundColor(.blue) + } + + Text("\(quantity)") + .font(.body) + .frame(width: 44, alignment: .center) + + Button(action: { + quantity += 1 + }) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(.blue) + } + + Spacer() + } + .padding(.horizontal) + } + .padding(.horizontal) + .padding() + + + + List { + ForEach(partStore.parts.indices, id: \.self) { idx in + let p = partStore.parts[idx] + HStack { + AsyncImage(url: URL(string: p.image)) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + Color.gray.opacity(0.3) + } + } + .frame(width: 48, height: 48) + .clipped() + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 4) { + Text(p.korName) + .font(.subheadline) + Text("\(p.model) / \(p.trim)") + .font(.caption) + .foregroundColor(.gray) + Text("\(p.price) 원") + .font(.subheadline) + } + + Spacer() + + // ✅ 명확한 수량 표시 + +/- 버튼 (Stepper 대신 커스텀 컨트롤) + HStack(spacing: 8) { + Button { + let newQty = max(1, p.quantity - 1) + partStore.updateQuantity(for: p.id, to: newQty) + } label: { + Image(systemName: "minus.circle") + .font(.title2) + } + + Text("\(p.quantity)") + .font(.body) + .frame(width: 44, alignment: .center) + + Button { + let newQty = p.quantity + 1 + partStore.updateQuantity(for: p.id, to: newQty) + } label: { + Image(systemName: "plus.circle") + .font(.title2) + } + } + .buttonStyle(.plain) + .foregroundColor(.blue) + } + .padding() + } + .onDelete { idxs in + idxs.forEach { i in + let id = partStore.parts[i].id + partStore.removePart(id) + } + } + } + .frame(maxHeight: 240) + .listStyle(.plain) +// List { +// ForEach(partStore.parts) { p in +// HStack { +// AsyncImage(url: URL(string: p.image)) { phase in +// switch phase { +// case .success(let img): +// img.resizable().scaledToFill() +// default: +// Color.gray.opacity(0.3) +// } +// } +// .frame(width: 48, height: 48) +// .clipped() +// .cornerRadius(6) +// +// VStack(alignment: .leading, spacing: 4) { +// Text(p.korName) +// .font(.subheadline) +// Text("\(p.model) / \(p.trim)") +// .font(.caption) +// .foregroundColor(.gray) +// } +// +// Spacer() +// +// Stepper("", value: Binding( +// get: { p.quantity }, +// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } +// ), in: 1...999) +// .labelsHidden() +// } +// .padding(.vertical, 6) +// } +// .onDelete { idxs in +// idxs.forEach { i in +// let id = partStore.parts[i].id +// partStore.removePart(id) +// } +// } +// } +// .frame(maxHeight: 240) +// .listStyle(.plain) + + Spacer() + + // — 하단 버튼 — + HStack(spacing: 12) { + Button { + var newPart = part + newPart.quantity = quantity + partStore.addPart(newPart) + onAddPart() + dismiss() + } label: { + Text("부품 추가") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + + Button { + Task { await handleUseNow() } + } label: { + if isProcessing { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("사용 처리") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .buttonStyle(.bordered) + .disabled(isProcessing) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + .presentationDetents([.medium, .large]) + .onAppear { + quantity = part.quantity + } + .alert("알림", isPresented: $showAlert) { + Button("확인") {} + } message: { + Text(alertMessage) + } + } + + private func handleUseNow() async { + await MainActor.run { isProcessing = true } + + if !partStore.parts.contains(where: { $0.id == part.id }) { + var newPart = part + newPart.quantity = quantity + partStore.addPart(newPart) + } else { + partStore.updateQuantity(for: part.id, to: quantity) + } + + let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } + let result = await partViewModel.releaseParts(items: items) + + await MainActor.run { + isProcessing = false + switch result { + case .success(let msg): + alertMessage = msg + showAlert = true + partStore.clear() + onUseParts() + dismiss() + case .failure(let err): + alertMessage = err.message + showAlert = true + } + } + } +} +//struct PartBottomSheetView: View { +// @EnvironmentObject var partStore: PartStore +// @Environment(\.dismiss) private var dismiss +// @StateObject private var partViewModel = PartViewModel() +// +// // 네가 원했던 시그니처: 반드시 이대로 호출 가능함 +// let part: PartDetail // 방금 스캔한 파트 (OutgoingScanView에서 전달) +// let onAddPart: () -> Void // "부품 추가" 눌렀을 때 호출 (OutgoingScanView에서 resetScanState 등 처리) +// let onUseParts: () -> Void // "사용 처리" 눌렀을 때 호출(옵션적 추가 동작) +// +// @State private var quantity: Int = 1 +// @State private var isProcessing = false +// @State private var showAlert = false +// @State private var alertMessage = "" +// +// var body: some View { +// VStack(spacing: 12) { +// // — 단일(방금 스캔한) 파트 미리보기 — +// HStack(spacing: 12) { +// AsyncImage(url: URL(string: part.image)) { phase in +// switch phase { +// case .success(let img): +// img.resizable().scaledToFill() +// default: +// Color.gray.opacity(0.3) +// } +// } +// .frame(width: 84, height: 84) +// .clipped() +// .cornerRadius(8) +// +// VStack(alignment: .leading, spacing: 6) { +// Text(part.korName) +// .font(.headline) +// Text("\(part.model) / \(part.trim)") +// .font(.subheadline) +// .foregroundColor(.gray) +// Text("₩\(part.price)") +// .font(.subheadline) +// } +// +// Spacer() +// } +// .padding(.horizontal) +// +// // 수량 조절 (바로 이 시트에서 조정한 수량이 추가/전송에 반영됨) +// Stepper("수량: \(quantity)", value: $quantity, in: 1...999) +// .padding(.horizontal) +// +// Divider() +// .padding(.vertical, 6) +// +// // — 누적된 파트 리스트 (PartStore) — +// Text("추가된 부품 목록") +// .font(.subheadline) +// .bold() +// .padding(.horizontal) +// +// List { +// ForEach(partStore.parts) { p in +// HStack { +// AsyncImage(url: URL(string: p.image)) { phase in +// switch phase { +// case .success(let img): +// img.resizable().scaledToFill() +// default: +// Color.gray.opacity(0.3) +// } +// } +// .frame(width: 48, height: 48) +// .clipped() +// .cornerRadius(6) +// +// VStack(alignment: .leading, spacing: 4) { +// Text(p.korName) +// .font(.subheadline) +// Text("\(p.model) / \(p.trim)") +// .font(.caption) +// .foregroundColor(.gray) +// } +// +// Spacer() +// +// // stepper 바인딩은 store 업데이트로 연결 +// Stepper("", value: Binding( +// get: { p.quantity }, +// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } +// ), in: 1...999) +// .labelsHidden() +// } +// .padding(.vertical, 6) +// } +// .onDelete { idxs in +// idxs.forEach { i in +// let id = partStore.parts[i].id +// partStore.removePart(id) +// } +// } +// } +// .frame(maxHeight: 240) +// .listStyle(.plain) +// +// Spacer() +// +// // 하단 버튼 +// HStack(spacing: 12) { +// // 1) 부품 추가: 방금 스캔한 파트를 (현재 quantity로) partStore에 추가하고 +// // 호출자에게 알려줌 (예: resetScanState) +// Button { +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// +// // 호출자 (OutgoingScanView)에게 알림 — 여기서 스캔 상태 초기화 등 처리 +// onAddPart() +// // 시트 닫기 +// dismiss() +// } label: { +// Text("부품 추가") +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } +// .buttonStyle(.borderedProminent) +// +// // 2) 사용 처리: PartStore 전체를 서버로 보내는 흐름 (뷰모델 사용) +// Button { +// Task { +// await handleUseNow() +// } +// } label: { +// if isProcessing { +// ProgressView() +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } else { +// Text("사용 처리") +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } +// } +// .buttonStyle(.bordered) +// .disabled(isProcessing) +// } +// .padding(.horizontal) +// .padding(.bottom, 8) +// } +// .presentationDetents([.medium, .large]) +// .onAppear { +// quantity = part.quantity +// } +// .alert("알림", isPresented: $showAlert) { +// Button("확인") {} +// } message: { +// Text(alertMessage) +// } +// } +// +// // 사용 처리 로직 (뷰모델에 구현된 releaseParts 사용) +// private func handleUseNow() async { +// await MainActor.run { isProcessing = true } +// +// // 현재 시트에서 조정한 수량이 partStore에 반영되어야 함: +// if !partStore.parts.contains(where: { $0.id == part.id }) { +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// } else { +// partStore.updateQuantity(for: part.id, to: quantity) +// } +// +// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } +// let result = await partViewModel.releaseParts(items: items) +// +// await MainActor.run { +// isProcessing = false +// switch result { +// case .success(let msg): +// alertMessage = msg +// showAlert = true +// // 성공 시 리스트 비우기 +// partStore.clear() +// // 호출자도 알림을 원하면 onUseParts 호출 +// onUseParts() +// dismiss() +// case .failure(let err): +// alertMessage = err.message +// showAlert = true +// } +// } +// } +//} + +//import SwiftUI +// +//struct PartBottomSheetView: View { +// @EnvironmentObject var partStore: PartStore +// @Environment(\.dismiss) private var dismiss +// @StateObject private var partViewModel = PartViewModel() +// +// var body: some View { +// VStack(spacing: 16) { +// Text("추가된 부품 목록") +// .font(.headline) +// .padding(.top, 8) +// +// List { +// ForEach(partStore.parts) { part in +// VStack(alignment: .leading, spacing: 4) { +// HStack { +// AsyncImage(url: URL(string: part.image)) { image in +// image.resizable().scaledToFit() +// } placeholder: { +// Color.gray +// } +// .frame(width: 60, height: 60) +// .cornerRadius(8) +// +// VStack(alignment: .leading) { +// Text(part.korName).font(.headline) +// Text("\(part.model) / \(part.trim)") +// .font(.subheadline) +// .foregroundColor(.gray) +// } +// +// Spacer() +// +// Stepper("", value: Binding( +// get: { part.quantity }, +// set: { newValue in +// partStore.updateQuantity(for: part.id, to: newValue) +// } +// ), in: 1...100) +// .labelsHidden() +// } +// +// Text("₩\(part.price)") +// .font(.subheadline) +// .foregroundColor(.secondary) +// } +// .padding(.vertical, 4) +// } +// .onDelete { indexSet in +// indexSet.forEach { idx in +// partStore.removePart(partStore.parts[idx].id) +// } +// } +// } +// +// HStack { +// Button("부품 추가") { +// dismiss() // 다시 QR 스캔 화면으로 복귀 +// } +// .buttonStyle(.borderedProminent) +// +// Button("사용 처리") { +// Task { +// let payload = partStore.parts.map { +// ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) +// } +// let result = await partViewModel.releaseParts(items: payload) +// switch result { +// case .success(let msg): +// print("✅ 사용처리 성공:", msg) +// partStore.clear() +// dismiss() +// case .failure(let err): +// print("❌ 실패:", err.message) +// } +// } +// } +// .buttonStyle(.bordered) +// } +// .padding() +// } +// .presentationDetents([.medium, .large]) +// } +//} + +//import SwiftUI +// +//struct PartBottomSheetView: View { +// @EnvironmentObject var partStore: PartStore +// @Environment(\.dismiss) private var dismiss +// +// // part: 지금 방금 API로 받아온 파트 (아직 PartStore에 추가되지 않을 수 있음) +// let part: PartDetail +// +// @State private var quantity = 1 +// @State private var isProcessingUse = false +// @State private var showErrorAlert = false +// @State private var errorMessage = "" +// +// var body: some View { +// VStack(spacing: 12) { +// AsyncImage(url: URL(string: part.image)) { phase in +// switch phase { +// case .success(let image): +// image.resizable().scaledToFit() +// default: +// Color.gray +// } +// } +// .frame(height: 150) +// +// Text(part.korName) +// .font(.headline) +// Text("\(part.model) / \(part.trim)") +// .font(.subheadline) +// Text("₩\(part.price)") +// .font(.title3) +// .bold() +// +// Stepper("수량: \(quantity)", value: $quantity, in: 1...100) +// .padding(.vertical) +// +// HStack(spacing: 12) { +// // 1) 부품 추가: 현재 파트를 PartStore에 추가한 뒤 닫아서 QR 스캔 화면으로 돌아감 +// Button(action: { +// addCurrentPartToStoreAndReturnToScanner() +// }) { +// Text("부품 추가") +// .frame(maxWidth: .infinity) +// } +// .buttonStyle(.bordered) +// +// // 2) 사용 처리: PartStore의 항목들(및 현재 파트가 미추가 상태면 그것도 포함)을 서버로 전송 +// Button(action: { +// Task { +// await handleUseNow() +// } +// }) { +// if isProcessingUse { +// ProgressView() +// .frame(maxWidth: .infinity) +// } else { +// Text("사용 처리") +// .frame(maxWidth: .infinity) +// } +// } +// .buttonStyle(.borderedProminent) +// .disabled(isProcessingUse) +// } +// } +// .padding() +// .alert("오류", isPresented: $showErrorAlert) { +// Button("확인", role: .cancel) { } +// } message: { +// Text(errorMessage) +// } +// .onAppear { +// // 기본 수량을 part의 quantity가 이미 설정되어 있으면 그걸로. (일반적으로 1) +// quantity = part.quantity +// } +// } +// +// // MARK: - Helpers +// +// // 부품 추가 누르면 현재 파트를 PartStore에 넣고 바텀시트 닫음 (QR 스캔 화면으로 돌아감) +// private func addCurrentPartToStoreAndReturnToScanner() { +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// dismiss() // QR 화면으로 돌아감 (QR 화면이 스택에 남아있다면 바로 보일 것) +// } +// +// // 사용 처리: 현재 파트를 포함해 전부 전송 +// private func handleUseNow() async { +// isProcessingUse = true +// +// // 현재 part가 이미 store에 있는지 확인 +// if !partStore.parts.contains(where: { $0.id == part.id }) { +// // 자동으로 포함시켜서 누락 방지 +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// } else { +// // 이미 있으면 사용자가 바텀시트에서 조정한 수량을 반영 +// partStore.updateQuantity(for: part.id, to: quantity) +// } +// +// // payload 생성 +// let payload = partStore.makeRequestPayload() +// // ReleaseItemRequest로 변환 (예시) +// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } +// +// // 실제로는 네가 사용 중인 ViewModel/Repository를 호출하는 게 좋음 (여기서는 간단 시도) +// let repository = PartRepositoryImpl() +// let result = await repository.releaseParts(items: items) +// +// await MainActor.run { +// isProcessingUse = false +// switch result { +// case .success(let apiResponse): +// // 성공 메시지 처리 — 필요하면 화면 닫고 partStore.clear() 호출 +// // 예: 사용 처리가 성공하면 임시 저장 리스트 비우기 +// partStore.parts.removeAll() +// dismiss() +// case .failure(let appError): +// // 실패 시 메시지 보여주기 +// errorMessage = appError.message +// showErrorAlert = true +// } +// } +// } +//} diff --git a/StockMate/StockMate/app/feature/parts/data/PartApi.swift b/StockMate/StockMate/app/feature/parts/data/PartApi.swift index ede241d..be73d35 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartApi.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -15,8 +15,7 @@ struct ReleaseItemRequest: Encodable { let quantity: Int } -// ✅ 부품 상세 정보 모델 -struct PartDetailResponse: Decodable { +struct PartDetailResponse: Decodable, Identifiable { let id: Int let name: String let price: Int @@ -33,6 +32,18 @@ struct PartDetailResponse: Decodable { let cost: Int } +// 사용처리 임시 값 +struct PartDetail: Identifiable, Equatable { + let id: Int + let price: Int + let image: String + let trim: String + let model: String + let korName: String + let categoryName: String + var quantity: Int = 1 +} + // ✅ API 정의 enum PartApi { @@ -50,18 +61,17 @@ enum PartApi { encoding: JSONEncoding.default ) } - + // ✅ 부품 상세 조회 API - static func fetchPartDetail(partId: Int) -> DataRequest { - let url = ApiClient.baseURL + "api/v1/parts/detail" - let body: [String: Any] = ["partId": partId] - - return ApiClient.shared.request( - url, - method: .post, - parameters: body, - encoding: JSONEncoding.default - ) - } - + static func fetchPartDetail(partIds: [Int]) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/parts/detail" + + // ✅ 요청 본문은 단순 배열 형태이므로 parameters 사용 X, 직접 body에 encode + return ApiClient.shared.request( + url, + method: .post, + parameters: partIds, + encoder: JSONParameterEncoder.default + ) + } } diff --git a/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift index 8a063cc..cc577f5 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift @@ -14,9 +14,9 @@ final class PartRepositoryImpl: PartRepositoryProtocol { return await safeApi(dataReq, decodeTo: ApiResponse.self) } - // ✅ 부품 상세 조회 API - func fetchPartDetail(partId: Int) async -> AppResult> { - let dataReq = PartApi.fetchPartDetail(partId: partId) + func fetchPartDetail(partIds: [Int]) async -> AppResult> { + let dataReq = PartApi.fetchPartDetail(partIds: partIds) return await safeApi(dataReq, decodeTo: ApiResponse<[PartDetailResponse]>.self) } + } diff --git a/StockMate/StockMate/app/feature/parts/data/PartStore.swift b/StockMate/StockMate/app/feature/parts/data/PartStore.swift new file mode 100644 index 0000000..4217933 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartStore.swift @@ -0,0 +1,40 @@ +// +// PartStore.swift +// StockMate +// +// Created by Admin on 11/3/25. +// + +import Foundation + +@MainActor +final class PartStore: ObservableObject { + @Published var parts: [PartDetail] = [] + + func addPart(_ part: PartDetail) { + if let index = parts.firstIndex(where: { $0.id == part.id }) { + // 이미 있으면 수량만 1 증가 + parts[index].quantity += 1 + } else { + parts.append(part) + } + } + + func updateQuantity(for partId: Int, to quantity: Int) { + if let index = parts.firstIndex(where: { $0.id == partId }) { + parts[index].quantity = quantity + } + } + + func removePart(_ partId: Int) { + parts.removeAll { $0.id == partId } + } + + func makeRequestPayload() -> [[String: Any]] { + parts.map { ["partId": $0.id, "quantity": $0.quantity] } + } + + func clear() { + parts.removeAll() + } +} diff --git a/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift index 3e028d3..14f73c1 100644 --- a/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift @@ -9,5 +9,5 @@ import Foundation protocol PartRepositoryProtocol { func releaseParts(items: [ReleaseItemRequest]) async -> AppResult> - func fetchPartDetail(partId: Int) async -> AppResult> + func fetchPartDetail(partIds: [Int]) async -> AppResult> } diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift index 761468f..c8a6a30 100644 --- a/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift @@ -16,8 +16,14 @@ struct QRScannerView: UIViewControllerRepresentable { return controller } - func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} - +// func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) { + // ✅ scannedCode가 nil이면 다시 스캔 시작 + if scannedCode == nil { + uiViewController.startScanning() + } + } + func makeCoordinator() -> Coordinator { Coordinator(self) } diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift index f338a55..e36d7b4 100644 --- a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift @@ -51,13 +51,32 @@ final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputOb previewLayer.videoGravity = .resizeAspectFill view.layer.addSublayer(previewLayer) + + startScanning() // ⚠️ 백그라운드에서 실행 - DispatchQueue.global(qos: .userInitiated).async { - self.captureSession.startRunning() +// DispatchQueue.global(qos: .userInitiated).async { +// self.captureSession.startRunning() +// } + } + + // ✅ 스캔 재시작/중단 함수 추가 + func startScanning() { + guard captureSession != nil else { return } + if !captureSession.isRunning { + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } } } - + func stopScanning() { + guard captureSession != nil else { return } + if captureSession.isRunning { + captureSession.stopRunning() + } + } + + // ✅ QR 감지 시 호출 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let stringValue = metadataObject.stringValue { diff --git a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift index ead6256..03543f8 100644 --- a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift +++ b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift @@ -15,59 +15,16 @@ final class PartViewModel: ObservableObject { @Published var shouldGoToLogin = false - // ✅ 임시 저장 리스트 - @Published var selectedParts: [PartDetailResponse] = [] - @Published var quantities: [Int: Int] = [:] // partId별 수량 관리 + @Published var partDetails: [PartDetailResponse] = [] + @Published var quantities: [Int: Int] = [:] // partId별 수량 관리 private let repo: PartRepositoryProtocol init(repo: PartRepositoryProtocol = PartRepositoryImpl()) { self.repo = repo } - - // ✅ 부품 상세 조회 (QR 스캔 후) - func fetchPartDetail(partId: Int) async { - isLoading = true - defer { isLoading = false } - - let result = await repo.fetchPartDetail(partId: partId) - switch result { - case .success(let apiResp): - if let detail = apiResp.data?.first { - if !selectedParts.contains(where: { $0.id == detail.id }) { - selectedParts.append(detail) - quantities[detail.id] = 1 - } else { - quantities[detail.id, default: 1] += 1 - } - } - case .failure(let error): - message = error.message - } - } - - func increaseQuantity(for partId: Int) { - quantities[partId, default: 1] += 1 - } - - func decreaseQuantity(for partId: Int) { - quantities[partId] = max(1, (quantities[partId] ?? 1) - 1) - } - - func removePart(partId: Int) { - selectedParts.removeAll { $0.id == partId } - quantities.removeValue(forKey: partId) - } - func makeReleasePayload() -> [ReleaseItemRequest] { - selectedParts.map { part in - ReleaseItemRequest(partId: part.id, quantity: quantities[part.id] ?? 1) - } - } - - - func releaseParts(items: [ReleaseItemRequest]) async -> AppResult { isLoading = true defer { isLoading = false } @@ -90,5 +47,25 @@ final class PartViewModel: ObservableObject { } } + func fetchPartDetail(partId: Int) async { + isLoading = true + defer { isLoading = false } + + let result = await repo.fetchPartDetail(partIds: [partId]) + switch result { + case .success(let apiResp): + if apiResp.success, let data = apiResp.data { + partDetails = data + print("✅ 부품 상세 조회 성공:", data) + } else { + message = apiResp.message + print("⚠️ 서버 응답 실패:", apiResp.message) + } + case .failure(let err): + message = err.message + print("❌ 네트워크 오류:", err) + } + } + } diff --git a/StockMate/StockMate/app/navigation/AppNavHost.swift b/StockMate/StockMate/app/navigation/AppNavHost.swift index f2c5bca..c55ea0a 100644 --- a/StockMate/StockMate/app/navigation/AppNavHost.swift +++ b/StockMate/StockMate/app/navigation/AppNavHost.swift @@ -9,6 +9,8 @@ import SwiftUI struct AppNavHost: View { @EnvironmentObject var authViewModel: AuthViewModel + @StateObject private var partStore = PartStore() + var body: some View { NavigationStack { @@ -22,6 +24,7 @@ struct AppNavHost: View { RegisterView() case .authenticated: MainTabView() + .environmentObject(partStore) } } } diff --git a/StockMate/StockMate/app/navigation/MainTabView.swift b/StockMate/StockMate/app/navigation/MainTabView.swift index b5bf61c..acc21ef 100644 --- a/StockMate/StockMate/app/navigation/MainTabView.swift +++ b/StockMate/StockMate/app/navigation/MainTabView.swift @@ -22,7 +22,12 @@ struct MainTabView: View { case 1: NavigationStack{ OrderView(cartViewModel: cartVM) } //, inventoryViewModel: inventoryVM) } // case 1: NavigationStack{ OrderView() } case 2: - NavigationStack { InventoryView(selectedTab: $selectedTab, tabTappedTrigger: $tabTappedTrigger) } + InventoryView( + selectedTab: $selectedTab, + tabTappedTrigger: $tabTappedTrigger + ) + +// NavigationStack { InventoryView(selectedTab: $selectedTab, tabTappedTrigger: $tabTappedTrigger) } // case 3: NavigationStack{ ContentView() } // case 3: NavigationStack{ ReceiptView() } case 3: NavigationStack{ ProfileView() } From 5edc05fd9220e86c9fe0ae47c526507f8dcc80a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Tue, 4 Nov 2025 00:10:06 +0900 Subject: [PATCH 10/22] =?UTF-8?q?[FEAT]=20=EB=B6=80=ED=92=88=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=9E=84=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inventory/ui/OutgoingScanView.swift | 9 +- .../inventory/ui/PartBottomSheetView.swift | 414 ++++++++++++------ .../app/feature/parts/data/PartStore.swift | 8 +- 3 files changed, 305 insertions(+), 126 deletions(-) diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index a6b11cb..280a9ee 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -105,9 +105,14 @@ struct OutgoingScanView: View { onAddPart: { // ✅ 부품 추가 버튼 액션 partStore.addPart(part) + + // ✅ UI가 업데이트될 시간을 준 후 스캐너 리셋 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + resetScanState() + } // ✅ 상태 초기화 (QR 다시 가능하도록) - resetScanState() +// resetScanState() }, onUseParts: { // ✅ 사용 처리 버튼 액션 (예: 서버 전송) @@ -261,7 +266,7 @@ struct OutgoingScanView: View { scannedCode = nil partDetail = nil showBottomSheet = false - partViewModel.partDetails.removeAll() +// partViewModel.partDetails.removeAll() // ✅ 카메라 세션 재시작 scannerRestartTrigger.toggle() diff --git a/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift index 827e919..8075a0a 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift @@ -24,179 +24,91 @@ struct PartBottomSheetView: View { var body: some View { VStack(spacing: 16) { - // MARK: - 방금 스캔한 부품 미리보기 (추가 전) + // ✅ 방금 스캔한 부품 미리보기 HStack(spacing: 12) { AsyncImage(url: URL(string: part.image)) { phase in switch phase { - case .success(let img): - img.resizable().scaledToFill() - default: - Color.gray.opacity(0.3) + case .success(let img): img.resizable().scaledToFill() + default: Color.gray.opacity(0.3) } } .frame(width: 48, height: 48) - .clipped() .cornerRadius(6) VStack(alignment: .leading, spacing: 4) { - Text(part.korName) - .font(.subheadline) + Text(part.korName).font(.subheadline) Text("\(part.model) / \(part.trim)") - .font(.caption) - .foregroundColor(.gray) - Text("\(part.price) 원") - .font(.subheadline) + .font(.caption).foregroundColor(.gray) + Text("\(part.price) 원").font(.subheadline) } Spacer() - - // ✅ 수정된 수량 조절 구역 + HStack(spacing: 8) { - Button(action: { - if quantity > 1 { quantity -= 1 } - }) { + Button(action: { if quantity > 1 { quantity -= 1 } }) { Image(systemName: "minus.circle.fill") - .font(.title2) - .foregroundColor(.blue) + .font(.title2).foregroundColor(.blue) } - - Text("\(quantity)") - .font(.body) - .frame(width: 44, alignment: .center) - - Button(action: { - quantity += 1 - }) { + Text("\(quantity)").frame(width: 44) + Button(action: { quantity += 1 }) { Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundColor(.blue) + .font(.title2).foregroundColor(.blue) } - - Spacer() } - .padding(.horizontal) } .padding(.horizontal) - .padding() - - + .padding(.vertical, 8) + // ✅ 현재까지 누적된 부품 리스트 List { - ForEach(partStore.parts.indices, id: \.self) { idx in - let p = partStore.parts[idx] + ForEach(partStore.parts) { p in HStack { AsyncImage(url: URL(string: p.image)) { phase in switch phase { - case .success(let img): - img.resizable().scaledToFill() - default: - Color.gray.opacity(0.3) + case .success(let img): img.resizable().scaledToFill() + default: Color.gray.opacity(0.3) } } .frame(width: 48, height: 48) - .clipped() .cornerRadius(6) VStack(alignment: .leading, spacing: 4) { - Text(p.korName) - .font(.subheadline) + Text(p.korName).font(.subheadline) Text("\(p.model) / \(p.trim)") - .font(.caption) - .foregroundColor(.gray) - Text("\(p.price) 원") - .font(.subheadline) + .font(.caption).foregroundColor(.gray) } Spacer() - // ✅ 명확한 수량 표시 + +/- 버튼 (Stepper 대신 커스텀 컨트롤) HStack(spacing: 8) { - Button { - let newQty = max(1, p.quantity - 1) - partStore.updateQuantity(for: p.id, to: newQty) - } label: { - Image(systemName: "minus.circle") - .font(.title2) + Button { partStore.updateQuantity(for: p.id, to: max(1, p.quantity - 1)) } label: { + Image(systemName: "minus.circle").font(.title2) } - - Text("\(p.quantity)") - .font(.body) - .frame(width: 44, alignment: .center) - - Button { - let newQty = p.quantity + 1 - partStore.updateQuantity(for: p.id, to: newQty) - } label: { - Image(systemName: "plus.circle") - .font(.title2) + Text("\(p.quantity)").frame(width: 44) + Button { partStore.updateQuantity(for: p.id, to: p.quantity + 1) } label: { + Image(systemName: "plus.circle").font(.title2) } } - .buttonStyle(.plain) .foregroundColor(.blue) } - .padding() + .padding(.vertical, 4) } .onDelete { idxs in - idxs.forEach { i in - let id = partStore.parts[i].id - partStore.removePart(id) - } + idxs.forEach { partStore.removePart(partStore.parts[$0].id) } } } .frame(maxHeight: 240) .listStyle(.plain) -// List { -// ForEach(partStore.parts) { p in -// HStack { -// AsyncImage(url: URL(string: p.image)) { phase in -// switch phase { -// case .success(let img): -// img.resizable().scaledToFill() -// default: -// Color.gray.opacity(0.3) -// } -// } -// .frame(width: 48, height: 48) -// .clipped() -// .cornerRadius(6) -// -// VStack(alignment: .leading, spacing: 4) { -// Text(p.korName) -// .font(.subheadline) -// Text("\(p.model) / \(p.trim)") -// .font(.caption) -// .foregroundColor(.gray) -// } -// -// Spacer() -// -// Stepper("", value: Binding( -// get: { p.quantity }, -// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } -// ), in: 1...999) -// .labelsHidden() -// } -// .padding(.vertical, 6) -// } -// .onDelete { idxs in -// idxs.forEach { i in -// let id = partStore.parts[i].id -// partStore.removePart(id) -// } -// } -// } -// .frame(maxHeight: 240) -// .listStyle(.plain) Spacer() - // — 하단 버튼 — + // ✅ 하단 버튼 HStack(spacing: 12) { Button { var newPart = part newPart.quantity = quantity partStore.addPart(newPart) - onAddPart() +// onAddPart() // QR 다시 준비 dismiss() } label: { Text("부품 추가") @@ -225,19 +137,16 @@ struct PartBottomSheetView: View { .padding(.bottom, 8) } .presentationDetents([.medium, .large]) - .onAppear { - quantity = part.quantity - } + .onAppear { quantity = part.quantity } .alert("알림", isPresented: $showAlert) { Button("확인") {} - } message: { - Text(alertMessage) - } + } message: { Text(alertMessage) } } private func handleUseNow() async { await MainActor.run { isProcessing = true } + // ✅ 현재 부품이 아직 store에 없으면 추가 if !partStore.parts.contains(where: { $0.id == part.id }) { var newPart = part newPart.quantity = quantity @@ -265,6 +174,267 @@ struct PartBottomSheetView: View { } } } + +//import SwiftUI +// +//struct PartBottomSheetView: View { +// @EnvironmentObject var partStore: PartStore +// @Environment(\.dismiss) private var dismiss +// @StateObject private var partViewModel = PartViewModel() +// +// let part: PartDetail +// let onAddPart: () -> Void +// let onUseParts: () -> Void +// +// @State private var quantity: Int = 1 +// @State private var isProcessing = false +// @State private var showAlert = false +// @State private var alertMessage = "" +// +// var body: some View { +// VStack(spacing: 16) { +// +// // MARK: - 방금 스캔한 부품 미리보기 (추가 전) +// HStack(spacing: 12) { +// AsyncImage(url: URL(string: part.image)) { phase in +// switch phase { +// case .success(let img): +// img.resizable().scaledToFill() +// default: +// Color.gray.opacity(0.3) +// } +// } +// .frame(width: 48, height: 48) +// .clipped() +// .cornerRadius(6) +// +// VStack(alignment: .leading, spacing: 4) { +// Text(part.korName) +// .font(.subheadline) +// Text("\(part.model) / \(part.trim)") +// .font(.caption) +// .foregroundColor(.gray) +// Text("\(part.price) 원") +// .font(.subheadline) +// } +// +// Spacer() +// +// // ✅ 수정된 수량 조절 구역 +// HStack(spacing: 8) { +// Button(action: { +// if quantity > 1 { quantity -= 1 } +// }) { +// Image(systemName: "minus.circle.fill") +// .font(.title2) +// .foregroundColor(.blue) +// } +// +// Text("\(quantity)") +// .font(.body) +// .frame(width: 44, alignment: .center) +// +// Button(action: { +// quantity += 1 +// }) { +// Image(systemName: "plus.circle.fill") +// .font(.title2) +// .foregroundColor(.blue) +// } +// +// Spacer() +// } +// .padding(.horizontal) +// } +// .padding(.horizontal) +// .padding() +// +// +// +// List { +// ForEach(partStore.parts.indices, id: \.self) { idx in +// let p = partStore.parts[idx] +// HStack { +// AsyncImage(url: URL(string: p.image)) { phase in +// switch phase { +// case .success(let img): +// img.resizable().scaledToFill() +// default: +// Color.gray.opacity(0.3) +// } +// } +// .frame(width: 48, height: 48) +// .clipped() +// .cornerRadius(6) +// +// VStack(alignment: .leading, spacing: 4) { +// Text(p.korName) +// .font(.subheadline) +// Text("\(p.model) / \(p.trim)") +// .font(.caption) +// .foregroundColor(.gray) +// Text("\(p.price) 원") +// .font(.subheadline) +// } +// +// Spacer() +// +// // ✅ 명확한 수량 표시 + +/- 버튼 (Stepper 대신 커스텀 컨트롤) +// HStack(spacing: 8) { +// Button { +// let newQty = max(1, p.quantity - 1) +// partStore.updateQuantity(for: p.id, to: newQty) +// } label: { +// Image(systemName: "minus.circle") +// .font(.title2) +// } +// +// Text("\(p.quantity)") +// .font(.body) +// .frame(width: 44, alignment: .center) +// +// Button { +// let newQty = p.quantity + 1 +// partStore.updateQuantity(for: p.id, to: newQty) +// } label: { +// Image(systemName: "plus.circle") +// .font(.title2) +// } +// } +// .buttonStyle(.plain) +// .foregroundColor(.blue) +// } +// .padding() +// } +// .onDelete { idxs in +// idxs.forEach { i in +// let id = partStore.parts[i].id +// partStore.removePart(id) +// } +// } +// } +// .frame(maxHeight: 240) +// .listStyle(.plain) +//// List { +//// ForEach(partStore.parts) { p in +//// HStack { +//// AsyncImage(url: URL(string: p.image)) { phase in +//// switch phase { +//// case .success(let img): +//// img.resizable().scaledToFill() +//// default: +//// Color.gray.opacity(0.3) +//// } +//// } +//// .frame(width: 48, height: 48) +//// .clipped() +//// .cornerRadius(6) +//// +//// VStack(alignment: .leading, spacing: 4) { +//// Text(p.korName) +//// .font(.subheadline) +//// Text("\(p.model) / \(p.trim)") +//// .font(.caption) +//// .foregroundColor(.gray) +//// } +//// +//// Spacer() +//// +//// Stepper("", value: Binding( +//// get: { p.quantity }, +//// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } +//// ), in: 1...999) +//// .labelsHidden() +//// } +//// .padding(.vertical, 6) +//// } +//// .onDelete { idxs in +//// idxs.forEach { i in +//// let id = partStore.parts[i].id +//// partStore.removePart(id) +//// } +//// } +//// } +//// .frame(maxHeight: 240) +//// .listStyle(.plain) +// +// Spacer() +// +// // — 하단 버튼 — +// HStack(spacing: 12) { +// Button { +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// onAddPart() +// dismiss() +// } label: { +// Text("부품 추가") +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } +// .buttonStyle(.borderedProminent) +// +// Button { +// Task { await handleUseNow() } +// } label: { +// if isProcessing { +// ProgressView() +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } else { +// Text("사용 처리") +// .frame(maxWidth: .infinity) +// .padding(.vertical, 12) +// } +// } +// .buttonStyle(.bordered) +// .disabled(isProcessing) +// } +// .padding(.horizontal) +// .padding(.bottom, 8) +// } +// .presentationDetents([.medium, .large]) +// .onAppear { +// quantity = part.quantity +// } +// .alert("알림", isPresented: $showAlert) { +// Button("확인") {} +// } message: { +// Text(alertMessage) +// } +// } +// +// private func handleUseNow() async { +// await MainActor.run { isProcessing = true } +// +// if !partStore.parts.contains(where: { $0.id == part.id }) { +// var newPart = part +// newPart.quantity = quantity +// partStore.addPart(newPart) +// } else { +// partStore.updateQuantity(for: part.id, to: quantity) +// } +// +// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } +// let result = await partViewModel.releaseParts(items: items) +// +// await MainActor.run { +// isProcessing = false +// switch result { +// case .success(let msg): +// alertMessage = msg +// showAlert = true +// partStore.clear() +// onUseParts() +// dismiss() +// case .failure(let err): +// alertMessage = err.message +// showAlert = true +// } +// } +// } +//} //struct PartBottomSheetView: View { // @EnvironmentObject var partStore: PartStore // @Environment(\.dismiss) private var dismiss diff --git a/StockMate/StockMate/app/feature/parts/data/PartStore.swift b/StockMate/StockMate/app/feature/parts/data/PartStore.swift index 4217933..5d31d76 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartStore.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartStore.swift @@ -14,9 +14,13 @@ final class PartStore: ObservableObject { func addPart(_ part: PartDetail) { if let index = parts.firstIndex(where: { $0.id == part.id }) { // 이미 있으면 수량만 1 증가 - parts[index].quantity += 1 +// parts[index].quantity += 1 } else { - parts.append(part) + // ✅ 반드시 새로운 복사본 append + var newPart = part + newPart.quantity = 1 + parts.append(newPart) +// parts.append(part) } } From e4ee73428f74ebf8478f4c480e8d288f61b7a1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Tue, 4 Nov 2025 01:27:48 +0900 Subject: [PATCH 11/22] =?UTF-8?q?[FEAT]=20=EB=B6=80=ED=92=88=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inventory/ui/OutgoingScanView.swift | 261 ++---- .../inventory/ui/PartBottomSheetView.swift | 842 ------------------ .../inventory/ui/UsedPartListSheetView.swift | 122 +++ .../app/feature/parts/data/PartApi.swift | 2 +- .../app/feature/parts/data/PartStore.swift | 35 +- .../app/feature/parts/ui/QRScannerView.swift | 17 +- .../parts/ui/QRScannerViewController.swift | 13 + 7 files changed, 254 insertions(+), 1038 deletions(-) delete mode 100644 StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift create mode 100644 StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index 280a9ee..a4d1105 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -14,24 +14,26 @@ struct OutgoingScanView: View { @State private var showAlert = false @State private var alertMessage = "" - // 사용 처리 임시 + // 전역 부품 저장소 @EnvironmentObject var partStore: PartStore + @StateObject private var partViewModel = PartViewModel() // ✅ ViewModel 추가 + @State private var showBottomSheet = false @State private var scannerRestartTrigger = false -// @State private var isPresentingBottomSheet = false @State private var partDetail: PartDetail? = nil - @StateObject private var partViewModel = PartViewModel() // ✅ ViewModel 추가 var body: some View { ZStack { // ✅ 카메라 미리보기 (QR 스캐너) - QRScannerView(scannedCode: $scannedCode) - .id(scannerRestartTrigger) // 👈 다시 렌더링되어 카메라 리셋됨 +// QRScannerView(scannedCode: $scannedCode) +// .ignoresSafeArea() + QRScannerView(scannedCode: $scannedCode, isActive: !showBottomSheet) .ignoresSafeArea() + // ✅ 스캔 가이드 및 UI 오버레이 VStack { Text("사용할 부품의 QR을 스캔해주세요") @@ -39,23 +41,23 @@ struct OutgoingScanView: View { .padding(.top, 60) .foregroundColor(.white) .shadow(radius: 2) - + Spacer() - + // 📷 스캔 박스 ZStack { RoundedRectangle(cornerRadius: 12) .fill(Color.clear) .frame(width: 250, height: 250) - + RoundedRectangle(cornerRadius: 8) .stroke(Color.green, lineWidth: 3) .frame(width: 220, height: 220) } .padding(.bottom, 180) - + Spacer() - + // 📦 직접 입력 버튼 Button(action: { dismiss() @@ -72,91 +74,72 @@ struct OutgoingScanView: View { .padding(.horizontal, 40) .padding(.bottom, 40) } - + // ✅ 로딩 인디케이터 if partViewModel.isLoading { - Color.black.opacity(0.3).ignoresSafeArea() - ProgressView("부품 조회 중...") - .padding() - .background(.ultraThinMaterial) - .cornerRadius(10) - } -// if partViewModel.isLoading { -// Color.black.opacity(0.3).ignoresSafeArea() -// ProgressView("부품 사용 처리 중...") -// .padding() -// .background(.ultraThinMaterial) -// .cornerRadius(10) -// } + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("부품 조회 중...") //ProgressView("부품 사용 처리 중...") + .padding() + .background(.ultraThinMaterial) + .cornerRadius(10) + } } .alert("알림", isPresented: $showAlert) { - Button("확인") {} - } message: { - Text(alertMessage) - } - .onChange(of: scannedCode) { newValue in - guard let code = newValue, !code.isEmpty else { return } - Task { await handleScannedCode(code) } - } - .sheet(isPresented: $showBottomSheet) { - if let part = partDetail { - PartBottomSheetView( - part: part, - onAddPart: { - // ✅ 부품 추가 버튼 액션 - partStore.addPart(part) - - // ✅ UI가 업데이트될 시간을 준 후 스캐너 리셋 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - resetScanState() - } - - // ✅ 상태 초기화 (QR 다시 가능하도록) -// resetScanState() - }, - onUseParts: { - // ✅ 사용 처리 버튼 액션 (예: 서버 전송) - let payload = partStore.makeRequestPayload() - print("🚀 사용 처리 API 호출 payload:", payload) - } - ) - .environmentObject(partStore) - } - } + Button("확인") {} + } message: { + Text(alertMessage) + } + .onChange(of: scannedCode) { newValue in + guard let code = newValue, !code.isEmpty else { return } + Task { await handleScannedCode(code) } + } + .sheet(isPresented: $showBottomSheet) { + UsedPartListSheetView( + onUseParts: { + Task { + // ✅ payload 생성 + let payload = partStore.parts.map { + ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) + } + + // ✅ API 호출 + let result = await partViewModel.releaseParts(items: payload) + + await MainActor.run { + switch result { + case .success(let message): + alertMessage = message + showAlert = true + + // ✅ 성공 시: 전역 부품 초기화 + 바텀시트 닫기 + 화면 복귀 + partStore.clear() + showBottomSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + dismiss() + } + + case .failure(let error): + alertMessage = error.message + showAlert = true + } + } + } + }, + onRescan: { + // ✅ 다시 스캔 버튼 눌렀을 때 + showBottomSheet = false + resetScanState() // QR 다시 활성화 + } + ) + .environmentObject(partStore) + } .navigationTitle("부품 사용 처리") .navigationBarTitleDisplayMode(.inline) -// .sheet(isPresented: $showBottomSheet) { -// PartBottomSheetView() -// .environmentObject(partStore) -// } -// .navigationTitle("부품 사용 처리") -// .navigationBarTitleDisplayMode(.inline) - - //-----// -// // ✅ 알림창 -// .alert("부품 사용 결과", isPresented: $showAlert) { -// Button("확인") { -// dismiss() -// } -// } message: { -// Text(alertMessage) -// } -// // ✅ QR 스캔 이벤트 발생 시 -// .onChange(of: scannedCode) { newValue in -// guard let code = newValue, !code.isEmpty else { return } -// Task { -// await handleScannedCode(code) -// } -// } -// .navigationTitle("부품 사용 처리") -// .navigationBarTitleDisplayMode(.inline) } // ✅ 스캔된 코드로 부품 상세 조회만 수행 (출고 X) private func handleScannedCode(_ code: String) async { - await MainActor.run { - partViewModel.isLoading = true - } + await MainActor.run { partViewModel.isLoading = true } // 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) guard let partId = Int(code) else { @@ -180,102 +163,34 @@ struct OutgoingScanView: View { showAlert = true return } - - partDetail = PartDetail( - id: response.id, - price: response.price, - image: response.image, - trim: response.trim, - model: response.model, - korName: response.korName, - categoryName: response.categoryName, - quantity: 1 - ) -// // ✅ API 응답을 PartDetail로 변환 -// let newPart = PartDetail( -// id: response.id, -// price: response.price, -// image: response.image, -// trim: response.trim, -// model: response.model, -// korName: response.korName, -// categoryName: response.categoryName, -// quantity: 1 -// ) -// -// // ✅ 리스트에 추가 -// partStore.addPart(newPart) - - // ✅ 바텀 시트 띄우기 - showBottomSheet = true - //-----/// + // ✅ 응답을 PartDetail로 변환 후 저장 + let newPart = PartDetail( + id: response.id, + price: response.price, + image: response.image, + trim: response.trim, + model: response.model, + korName: response.korName, + categoryName: response.categoryName, + quantity: 1 + ) -// if let part = partViewModel.partDetails.first { -// print("✅ 부품 상세 조회 성공") -// print("ID:", part.id) -// print("이름:", part.name) -// print("가격:", part.price) -// print("모델:", part.model) -// print("트림:", part.trim) -// print("코드:", part.code) -// alertMessage = "부품 조회 성공: \(part.name)" -// } else { -// print("⚠️ 부품 정보를 불러오지 못함") -// alertMessage = "부품 정보를 불러오지 못했습니다." -// } -// -// showAlert = true + partStore.addPart(newPart) + + // ✅ 자동으로 바텀시트 열기 + showBottomSheet = true + + // ✅ 스캔 상태 초기화 (다시 스캔 가능하도록) + resetScanState() + + print("✅ \(newPart.korName) 부품이 전역 Store에 추가됨") } } - -// // ✅ 스캔된 코드로 출고 API 호출 -// private func handleScannedCode(_ code: String) async { -// await MainActor.run { -// partViewModel.isLoading = true -// } -// -// // ✅ 문자열 → Int 변환 (QR 코드가 숫자 아닐 경우 예외 처리) -// guard let partId = Int(code) else { -// await MainActor.run { -// partViewModel.isLoading = false -// alertMessage = "잘못된 QR 코드입니다. (숫자형 ID가 아닙니다)" -// showAlert = true -// } -// return -// } -// -// // ✅ 요청 생성 및 API 호출 -// let request = [ReleaseItemRequest(partId: partId, quantity: 1)] // 기본 1개 사용 -// let result = await partViewModel.releaseParts(items: request) -// -// await MainActor.run { -// partViewModel.isLoading = false -// switch result { -// case .success(let message): -// alertMessage = message -// case .failure(let error): -// alertMessage = error.message -// } -// showAlert = true -// } -// } // ✅ 상태 초기화 (QR 다시 활성화) private func resetScanState() { scannedCode = nil - partDetail = nil - showBottomSheet = false -// partViewModel.partDetails.removeAll() - - // ✅ 카메라 세션 재시작 scannerRestartTrigger.toggle() } - } - -//#Preview { -// NavigationStack { -// OutgoingScanView() -// } -//} diff --git a/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift deleted file mode 100644 index 8075a0a..0000000 --- a/StockMate/StockMate/app/feature/inventory/ui/PartBottomSheetView.swift +++ /dev/null @@ -1,842 +0,0 @@ -// -// PartBottomSheetView.swift -// StockMate -// -// Created by Admin on 11/3/25. -// - -import SwiftUI - -struct PartBottomSheetView: View { - @EnvironmentObject var partStore: PartStore - @Environment(\.dismiss) private var dismiss - @StateObject private var partViewModel = PartViewModel() - - let part: PartDetail - let onAddPart: () -> Void - let onUseParts: () -> Void - - @State private var quantity: Int = 1 - @State private var isProcessing = false - @State private var showAlert = false - @State private var alertMessage = "" - - var body: some View { - VStack(spacing: 16) { - - // ✅ 방금 스캔한 부품 미리보기 - HStack(spacing: 12) { - AsyncImage(url: URL(string: part.image)) { phase in - switch phase { - case .success(let img): img.resizable().scaledToFill() - default: Color.gray.opacity(0.3) - } - } - .frame(width: 48, height: 48) - .cornerRadius(6) - - VStack(alignment: .leading, spacing: 4) { - Text(part.korName).font(.subheadline) - Text("\(part.model) / \(part.trim)") - .font(.caption).foregroundColor(.gray) - Text("\(part.price) 원").font(.subheadline) - } - - Spacer() - - HStack(spacing: 8) { - Button(action: { if quantity > 1 { quantity -= 1 } }) { - Image(systemName: "minus.circle.fill") - .font(.title2).foregroundColor(.blue) - } - Text("\(quantity)").frame(width: 44) - Button(action: { quantity += 1 }) { - Image(systemName: "plus.circle.fill") - .font(.title2).foregroundColor(.blue) - } - } - } - .padding(.horizontal) - .padding(.vertical, 8) - - // ✅ 현재까지 누적된 부품 리스트 - List { - ForEach(partStore.parts) { p in - HStack { - AsyncImage(url: URL(string: p.image)) { phase in - switch phase { - case .success(let img): img.resizable().scaledToFill() - default: Color.gray.opacity(0.3) - } - } - .frame(width: 48, height: 48) - .cornerRadius(6) - - VStack(alignment: .leading, spacing: 4) { - Text(p.korName).font(.subheadline) - Text("\(p.model) / \(p.trim)") - .font(.caption).foregroundColor(.gray) - } - - Spacer() - - HStack(spacing: 8) { - Button { partStore.updateQuantity(for: p.id, to: max(1, p.quantity - 1)) } label: { - Image(systemName: "minus.circle").font(.title2) - } - Text("\(p.quantity)").frame(width: 44) - Button { partStore.updateQuantity(for: p.id, to: p.quantity + 1) } label: { - Image(systemName: "plus.circle").font(.title2) - } - } - .foregroundColor(.blue) - } - .padding(.vertical, 4) - } - .onDelete { idxs in - idxs.forEach { partStore.removePart(partStore.parts[$0].id) } - } - } - .frame(maxHeight: 240) - .listStyle(.plain) - - Spacer() - - // ✅ 하단 버튼 - HStack(spacing: 12) { - Button { - var newPart = part - newPart.quantity = quantity - partStore.addPart(newPart) -// onAddPart() // QR 다시 준비 - dismiss() - } label: { - Text("부품 추가") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.borderedProminent) - - Button { - Task { await handleUseNow() } - } label: { - if isProcessing { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("사용 처리") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - } - .buttonStyle(.bordered) - .disabled(isProcessing) - } - .padding(.horizontal) - .padding(.bottom, 8) - } - .presentationDetents([.medium, .large]) - .onAppear { quantity = part.quantity } - .alert("알림", isPresented: $showAlert) { - Button("확인") {} - } message: { Text(alertMessage) } - } - - private func handleUseNow() async { - await MainActor.run { isProcessing = true } - - // ✅ 현재 부품이 아직 store에 없으면 추가 - if !partStore.parts.contains(where: { $0.id == part.id }) { - var newPart = part - newPart.quantity = quantity - partStore.addPart(newPart) - } else { - partStore.updateQuantity(for: part.id, to: quantity) - } - - let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } - let result = await partViewModel.releaseParts(items: items) - - await MainActor.run { - isProcessing = false - switch result { - case .success(let msg): - alertMessage = msg - showAlert = true - partStore.clear() - onUseParts() - dismiss() - case .failure(let err): - alertMessage = err.message - showAlert = true - } - } - } -} - -//import SwiftUI -// -//struct PartBottomSheetView: View { -// @EnvironmentObject var partStore: PartStore -// @Environment(\.dismiss) private var dismiss -// @StateObject private var partViewModel = PartViewModel() -// -// let part: PartDetail -// let onAddPart: () -> Void -// let onUseParts: () -> Void -// -// @State private var quantity: Int = 1 -// @State private var isProcessing = false -// @State private var showAlert = false -// @State private var alertMessage = "" -// -// var body: some View { -// VStack(spacing: 16) { -// -// // MARK: - 방금 스캔한 부품 미리보기 (추가 전) -// HStack(spacing: 12) { -// AsyncImage(url: URL(string: part.image)) { phase in -// switch phase { -// case .success(let img): -// img.resizable().scaledToFill() -// default: -// Color.gray.opacity(0.3) -// } -// } -// .frame(width: 48, height: 48) -// .clipped() -// .cornerRadius(6) -// -// VStack(alignment: .leading, spacing: 4) { -// Text(part.korName) -// .font(.subheadline) -// Text("\(part.model) / \(part.trim)") -// .font(.caption) -// .foregroundColor(.gray) -// Text("\(part.price) 원") -// .font(.subheadline) -// } -// -// Spacer() -// -// // ✅ 수정된 수량 조절 구역 -// HStack(spacing: 8) { -// Button(action: { -// if quantity > 1 { quantity -= 1 } -// }) { -// Image(systemName: "minus.circle.fill") -// .font(.title2) -// .foregroundColor(.blue) -// } -// -// Text("\(quantity)") -// .font(.body) -// .frame(width: 44, alignment: .center) -// -// Button(action: { -// quantity += 1 -// }) { -// Image(systemName: "plus.circle.fill") -// .font(.title2) -// .foregroundColor(.blue) -// } -// -// Spacer() -// } -// .padding(.horizontal) -// } -// .padding(.horizontal) -// .padding() -// -// -// -// List { -// ForEach(partStore.parts.indices, id: \.self) { idx in -// let p = partStore.parts[idx] -// HStack { -// AsyncImage(url: URL(string: p.image)) { phase in -// switch phase { -// case .success(let img): -// img.resizable().scaledToFill() -// default: -// Color.gray.opacity(0.3) -// } -// } -// .frame(width: 48, height: 48) -// .clipped() -// .cornerRadius(6) -// -// VStack(alignment: .leading, spacing: 4) { -// Text(p.korName) -// .font(.subheadline) -// Text("\(p.model) / \(p.trim)") -// .font(.caption) -// .foregroundColor(.gray) -// Text("\(p.price) 원") -// .font(.subheadline) -// } -// -// Spacer() -// -// // ✅ 명확한 수량 표시 + +/- 버튼 (Stepper 대신 커스텀 컨트롤) -// HStack(spacing: 8) { -// Button { -// let newQty = max(1, p.quantity - 1) -// partStore.updateQuantity(for: p.id, to: newQty) -// } label: { -// Image(systemName: "minus.circle") -// .font(.title2) -// } -// -// Text("\(p.quantity)") -// .font(.body) -// .frame(width: 44, alignment: .center) -// -// Button { -// let newQty = p.quantity + 1 -// partStore.updateQuantity(for: p.id, to: newQty) -// } label: { -// Image(systemName: "plus.circle") -// .font(.title2) -// } -// } -// .buttonStyle(.plain) -// .foregroundColor(.blue) -// } -// .padding() -// } -// .onDelete { idxs in -// idxs.forEach { i in -// let id = partStore.parts[i].id -// partStore.removePart(id) -// } -// } -// } -// .frame(maxHeight: 240) -// .listStyle(.plain) -//// List { -//// ForEach(partStore.parts) { p in -//// HStack { -//// AsyncImage(url: URL(string: p.image)) { phase in -//// switch phase { -//// case .success(let img): -//// img.resizable().scaledToFill() -//// default: -//// Color.gray.opacity(0.3) -//// } -//// } -//// .frame(width: 48, height: 48) -//// .clipped() -//// .cornerRadius(6) -//// -//// VStack(alignment: .leading, spacing: 4) { -//// Text(p.korName) -//// .font(.subheadline) -//// Text("\(p.model) / \(p.trim)") -//// .font(.caption) -//// .foregroundColor(.gray) -//// } -//// -//// Spacer() -//// -//// Stepper("", value: Binding( -//// get: { p.quantity }, -//// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } -//// ), in: 1...999) -//// .labelsHidden() -//// } -//// .padding(.vertical, 6) -//// } -//// .onDelete { idxs in -//// idxs.forEach { i in -//// let id = partStore.parts[i].id -//// partStore.removePart(id) -//// } -//// } -//// } -//// .frame(maxHeight: 240) -//// .listStyle(.plain) -// -// Spacer() -// -// // — 하단 버튼 — -// HStack(spacing: 12) { -// Button { -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// onAddPart() -// dismiss() -// } label: { -// Text("부품 추가") -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } -// .buttonStyle(.borderedProminent) -// -// Button { -// Task { await handleUseNow() } -// } label: { -// if isProcessing { -// ProgressView() -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } else { -// Text("사용 처리") -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } -// } -// .buttonStyle(.bordered) -// .disabled(isProcessing) -// } -// .padding(.horizontal) -// .padding(.bottom, 8) -// } -// .presentationDetents([.medium, .large]) -// .onAppear { -// quantity = part.quantity -// } -// .alert("알림", isPresented: $showAlert) { -// Button("확인") {} -// } message: { -// Text(alertMessage) -// } -// } -// -// private func handleUseNow() async { -// await MainActor.run { isProcessing = true } -// -// if !partStore.parts.contains(where: { $0.id == part.id }) { -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// } else { -// partStore.updateQuantity(for: part.id, to: quantity) -// } -// -// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } -// let result = await partViewModel.releaseParts(items: items) -// -// await MainActor.run { -// isProcessing = false -// switch result { -// case .success(let msg): -// alertMessage = msg -// showAlert = true -// partStore.clear() -// onUseParts() -// dismiss() -// case .failure(let err): -// alertMessage = err.message -// showAlert = true -// } -// } -// } -//} -//struct PartBottomSheetView: View { -// @EnvironmentObject var partStore: PartStore -// @Environment(\.dismiss) private var dismiss -// @StateObject private var partViewModel = PartViewModel() -// -// // 네가 원했던 시그니처: 반드시 이대로 호출 가능함 -// let part: PartDetail // 방금 스캔한 파트 (OutgoingScanView에서 전달) -// let onAddPart: () -> Void // "부품 추가" 눌렀을 때 호출 (OutgoingScanView에서 resetScanState 등 처리) -// let onUseParts: () -> Void // "사용 처리" 눌렀을 때 호출(옵션적 추가 동작) -// -// @State private var quantity: Int = 1 -// @State private var isProcessing = false -// @State private var showAlert = false -// @State private var alertMessage = "" -// -// var body: some View { -// VStack(spacing: 12) { -// // — 단일(방금 스캔한) 파트 미리보기 — -// HStack(spacing: 12) { -// AsyncImage(url: URL(string: part.image)) { phase in -// switch phase { -// case .success(let img): -// img.resizable().scaledToFill() -// default: -// Color.gray.opacity(0.3) -// } -// } -// .frame(width: 84, height: 84) -// .clipped() -// .cornerRadius(8) -// -// VStack(alignment: .leading, spacing: 6) { -// Text(part.korName) -// .font(.headline) -// Text("\(part.model) / \(part.trim)") -// .font(.subheadline) -// .foregroundColor(.gray) -// Text("₩\(part.price)") -// .font(.subheadline) -// } -// -// Spacer() -// } -// .padding(.horizontal) -// -// // 수량 조절 (바로 이 시트에서 조정한 수량이 추가/전송에 반영됨) -// Stepper("수량: \(quantity)", value: $quantity, in: 1...999) -// .padding(.horizontal) -// -// Divider() -// .padding(.vertical, 6) -// -// // — 누적된 파트 리스트 (PartStore) — -// Text("추가된 부품 목록") -// .font(.subheadline) -// .bold() -// .padding(.horizontal) -// -// List { -// ForEach(partStore.parts) { p in -// HStack { -// AsyncImage(url: URL(string: p.image)) { phase in -// switch phase { -// case .success(let img): -// img.resizable().scaledToFill() -// default: -// Color.gray.opacity(0.3) -// } -// } -// .frame(width: 48, height: 48) -// .clipped() -// .cornerRadius(6) -// -// VStack(alignment: .leading, spacing: 4) { -// Text(p.korName) -// .font(.subheadline) -// Text("\(p.model) / \(p.trim)") -// .font(.caption) -// .foregroundColor(.gray) -// } -// -// Spacer() -// -// // stepper 바인딩은 store 업데이트로 연결 -// Stepper("", value: Binding( -// get: { p.quantity }, -// set: { newVal in partStore.updateQuantity(for: p.id, to: newVal) } -// ), in: 1...999) -// .labelsHidden() -// } -// .padding(.vertical, 6) -// } -// .onDelete { idxs in -// idxs.forEach { i in -// let id = partStore.parts[i].id -// partStore.removePart(id) -// } -// } -// } -// .frame(maxHeight: 240) -// .listStyle(.plain) -// -// Spacer() -// -// // 하단 버튼 -// HStack(spacing: 12) { -// // 1) 부품 추가: 방금 스캔한 파트를 (현재 quantity로) partStore에 추가하고 -// // 호출자에게 알려줌 (예: resetScanState) -// Button { -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// -// // 호출자 (OutgoingScanView)에게 알림 — 여기서 스캔 상태 초기화 등 처리 -// onAddPart() -// // 시트 닫기 -// dismiss() -// } label: { -// Text("부품 추가") -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } -// .buttonStyle(.borderedProminent) -// -// // 2) 사용 처리: PartStore 전체를 서버로 보내는 흐름 (뷰모델 사용) -// Button { -// Task { -// await handleUseNow() -// } -// } label: { -// if isProcessing { -// ProgressView() -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } else { -// Text("사용 처리") -// .frame(maxWidth: .infinity) -// .padding(.vertical, 12) -// } -// } -// .buttonStyle(.bordered) -// .disabled(isProcessing) -// } -// .padding(.horizontal) -// .padding(.bottom, 8) -// } -// .presentationDetents([.medium, .large]) -// .onAppear { -// quantity = part.quantity -// } -// .alert("알림", isPresented: $showAlert) { -// Button("확인") {} -// } message: { -// Text(alertMessage) -// } -// } -// -// // 사용 처리 로직 (뷰모델에 구현된 releaseParts 사용) -// private func handleUseNow() async { -// await MainActor.run { isProcessing = true } -// -// // 현재 시트에서 조정한 수량이 partStore에 반영되어야 함: -// if !partStore.parts.contains(where: { $0.id == part.id }) { -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// } else { -// partStore.updateQuantity(for: part.id, to: quantity) -// } -// -// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } -// let result = await partViewModel.releaseParts(items: items) -// -// await MainActor.run { -// isProcessing = false -// switch result { -// case .success(let msg): -// alertMessage = msg -// showAlert = true -// // 성공 시 리스트 비우기 -// partStore.clear() -// // 호출자도 알림을 원하면 onUseParts 호출 -// onUseParts() -// dismiss() -// case .failure(let err): -// alertMessage = err.message -// showAlert = true -// } -// } -// } -//} - -//import SwiftUI -// -//struct PartBottomSheetView: View { -// @EnvironmentObject var partStore: PartStore -// @Environment(\.dismiss) private var dismiss -// @StateObject private var partViewModel = PartViewModel() -// -// var body: some View { -// VStack(spacing: 16) { -// Text("추가된 부품 목록") -// .font(.headline) -// .padding(.top, 8) -// -// List { -// ForEach(partStore.parts) { part in -// VStack(alignment: .leading, spacing: 4) { -// HStack { -// AsyncImage(url: URL(string: part.image)) { image in -// image.resizable().scaledToFit() -// } placeholder: { -// Color.gray -// } -// .frame(width: 60, height: 60) -// .cornerRadius(8) -// -// VStack(alignment: .leading) { -// Text(part.korName).font(.headline) -// Text("\(part.model) / \(part.trim)") -// .font(.subheadline) -// .foregroundColor(.gray) -// } -// -// Spacer() -// -// Stepper("", value: Binding( -// get: { part.quantity }, -// set: { newValue in -// partStore.updateQuantity(for: part.id, to: newValue) -// } -// ), in: 1...100) -// .labelsHidden() -// } -// -// Text("₩\(part.price)") -// .font(.subheadline) -// .foregroundColor(.secondary) -// } -// .padding(.vertical, 4) -// } -// .onDelete { indexSet in -// indexSet.forEach { idx in -// partStore.removePart(partStore.parts[idx].id) -// } -// } -// } -// -// HStack { -// Button("부품 추가") { -// dismiss() // 다시 QR 스캔 화면으로 복귀 -// } -// .buttonStyle(.borderedProminent) -// -// Button("사용 처리") { -// Task { -// let payload = partStore.parts.map { -// ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) -// } -// let result = await partViewModel.releaseParts(items: payload) -// switch result { -// case .success(let msg): -// print("✅ 사용처리 성공:", msg) -// partStore.clear() -// dismiss() -// case .failure(let err): -// print("❌ 실패:", err.message) -// } -// } -// } -// .buttonStyle(.bordered) -// } -// .padding() -// } -// .presentationDetents([.medium, .large]) -// } -//} - -//import SwiftUI -// -//struct PartBottomSheetView: View { -// @EnvironmentObject var partStore: PartStore -// @Environment(\.dismiss) private var dismiss -// -// // part: 지금 방금 API로 받아온 파트 (아직 PartStore에 추가되지 않을 수 있음) -// let part: PartDetail -// -// @State private var quantity = 1 -// @State private var isProcessingUse = false -// @State private var showErrorAlert = false -// @State private var errorMessage = "" -// -// var body: some View { -// VStack(spacing: 12) { -// AsyncImage(url: URL(string: part.image)) { phase in -// switch phase { -// case .success(let image): -// image.resizable().scaledToFit() -// default: -// Color.gray -// } -// } -// .frame(height: 150) -// -// Text(part.korName) -// .font(.headline) -// Text("\(part.model) / \(part.trim)") -// .font(.subheadline) -// Text("₩\(part.price)") -// .font(.title3) -// .bold() -// -// Stepper("수량: \(quantity)", value: $quantity, in: 1...100) -// .padding(.vertical) -// -// HStack(spacing: 12) { -// // 1) 부품 추가: 현재 파트를 PartStore에 추가한 뒤 닫아서 QR 스캔 화면으로 돌아감 -// Button(action: { -// addCurrentPartToStoreAndReturnToScanner() -// }) { -// Text("부품 추가") -// .frame(maxWidth: .infinity) -// } -// .buttonStyle(.bordered) -// -// // 2) 사용 처리: PartStore의 항목들(및 현재 파트가 미추가 상태면 그것도 포함)을 서버로 전송 -// Button(action: { -// Task { -// await handleUseNow() -// } -// }) { -// if isProcessingUse { -// ProgressView() -// .frame(maxWidth: .infinity) -// } else { -// Text("사용 처리") -// .frame(maxWidth: .infinity) -// } -// } -// .buttonStyle(.borderedProminent) -// .disabled(isProcessingUse) -// } -// } -// .padding() -// .alert("오류", isPresented: $showErrorAlert) { -// Button("확인", role: .cancel) { } -// } message: { -// Text(errorMessage) -// } -// .onAppear { -// // 기본 수량을 part의 quantity가 이미 설정되어 있으면 그걸로. (일반적으로 1) -// quantity = part.quantity -// } -// } -// -// // MARK: - Helpers -// -// // 부품 추가 누르면 현재 파트를 PartStore에 넣고 바텀시트 닫음 (QR 스캔 화면으로 돌아감) -// private func addCurrentPartToStoreAndReturnToScanner() { -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// dismiss() // QR 화면으로 돌아감 (QR 화면이 스택에 남아있다면 바로 보일 것) -// } -// -// // 사용 처리: 현재 파트를 포함해 전부 전송 -// private func handleUseNow() async { -// isProcessingUse = true -// -// // 현재 part가 이미 store에 있는지 확인 -// if !partStore.parts.contains(where: { $0.id == part.id }) { -// // 자동으로 포함시켜서 누락 방지 -// var newPart = part -// newPart.quantity = quantity -// partStore.addPart(newPart) -// } else { -// // 이미 있으면 사용자가 바텀시트에서 조정한 수량을 반영 -// partStore.updateQuantity(for: part.id, to: quantity) -// } -// -// // payload 생성 -// let payload = partStore.makeRequestPayload() -// // ReleaseItemRequest로 변환 (예시) -// let items = partStore.parts.map { ReleaseItemRequest(partId: $0.id, quantity: $0.quantity) } -// -// // 실제로는 네가 사용 중인 ViewModel/Repository를 호출하는 게 좋음 (여기서는 간단 시도) -// let repository = PartRepositoryImpl() -// let result = await repository.releaseParts(items: items) -// -// await MainActor.run { -// isProcessingUse = false -// switch result { -// case .success(let apiResponse): -// // 성공 메시지 처리 — 필요하면 화면 닫고 partStore.clear() 호출 -// // 예: 사용 처리가 성공하면 임시 저장 리스트 비우기 -// partStore.parts.removeAll() -// dismiss() -// case .failure(let appError): -// // 실패 시 메시지 보여주기 -// errorMessage = appError.message -// showErrorAlert = true -// } -// } -// } -//} diff --git a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift new file mode 100644 index 0000000..f81f320 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -0,0 +1,122 @@ +// +// UsedPartListSheetView.swift +// StockMate +// +// Created by Admin on 11/4/25. +// + + +// +// UsedPartListSheetView.swift +// StockMate +// +// Created by Admin on 11/4/25. +// + +import SwiftUI + +struct UsedPartListSheetView: View { + @EnvironmentObject var partStore: PartStore + var onUseParts: (() -> Void)? // ‘사용 처리’ 버튼 액션 콜백 + var onRescan: (() -> Void)? // ✅ 다시 스캔 콜백 추가 + + var body: some View { + NavigationStack { + VStack { + if partStore.parts.isEmpty { + VStack(spacing: 12) { + Image(systemName: "cube.box") + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .foregroundColor(.gray.opacity(0.6)) + Text("추가된 부품이 없습니다.") + .foregroundColor(.gray) + } + .padding(.top, 100) + } else { + ScrollView { + LazyVStack { + ForEach($partStore.parts) { $part in // ✅ 바인딩으로 변경 ($ 붙임) + HStack(spacing: 12) { + AsyncImage(url: URL(string: part.image)) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(part.korName) + .font(.headline) + Text(part.model) + .font(.subheadline) + .foregroundColor(.secondary) + Text(part.categoryName) + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + // ✅ 수량 조절 버튼 + HStack { + Button("-") { if part.quantity > 1 { part.quantity -= 1 } } + Text("\(part.quantity)") + Button("+") { part.quantity += 1 } + } + .padding(.trailing, 4) + } + .padding(.vertical, 4) + } + .onDelete { indexSet in + partStore.parts.remove(atOffsets: indexSet) + } + } + .listStyle(.plain) + } + } + + // 🔹 다시 스캔 버튼 + Button { + onRescan?() + } label: { + Text("다시 스캔") + .font(.headline) + .foregroundColor(.blue) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(10) + } + .padding(.bottom, 16) + + // ✅ 사용 처리 버튼 + Button { + onUseParts?() + } label: { + Text("사용 처리") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(partStore.parts.isEmpty ? Color.gray : Color.blue) + .cornerRadius(10) + .padding(.horizontal) + } + .disabled(partStore.parts.isEmpty) + .padding(.bottom, 16) + } + .navigationTitle("사용할 부품 목록") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("전체삭제") { + partStore.clear() + } + .foregroundColor(.red) + } + } + } + } +} diff --git a/StockMate/StockMate/app/feature/parts/data/PartApi.swift b/StockMate/StockMate/app/feature/parts/data/PartApi.swift index be73d35..e1d4050 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartApi.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -33,7 +33,7 @@ struct PartDetailResponse: Decodable, Identifiable { } // 사용처리 임시 값 -struct PartDetail: Identifiable, Equatable { +struct PartDetail: Identifiable { let id: Int let price: Int let image: String diff --git a/StockMate/StockMate/app/feature/parts/data/PartStore.swift b/StockMate/StockMate/app/feature/parts/data/PartStore.swift index 5d31d76..b769ce7 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartStore.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartStore.swift @@ -10,35 +10,34 @@ import Foundation @MainActor final class PartStore: ObservableObject { @Published var parts: [PartDetail] = [] - + func addPart(_ part: PartDetail) { if let index = parts.firstIndex(where: { $0.id == part.id }) { - // 이미 있으면 수량만 1 증가 -// parts[index].quantity += 1 + parts[index].quantity += 1 // 이미 존재하면 수량만 +1 } else { - // ✅ 반드시 새로운 복사본 append var newPart = part newPart.quantity = 1 parts.append(newPart) -// parts.append(part) - } - } - - func updateQuantity(for partId: Int, to quantity: Int) { - if let index = parts.firstIndex(where: { $0.id == partId }) { - parts[index].quantity = quantity } } - func removePart(_ partId: Int) { - parts.removeAll { $0.id == partId } + func clear() { + parts.removeAll() } - - func makeRequestPayload() -> [[String: Any]] { - parts.map { ["partId": $0.id, "quantity": $0.quantity] } + + // ✅ 수량 변경용 메서드 추가 + func increaseQuantity(for part: PartDetail) { + if let index = parts.firstIndex(where: { $0.id == part.id }) { + parts[index].quantity += 1 + objectWillChange.send() // 수동 갱신 트리거 + } } - func clear() { - parts.removeAll() + func decreaseQuantity(for part: PartDetail) { + if let index = parts.firstIndex(where: { $0.id == part.id }), + parts[index].quantity > 1 { + parts[index].quantity -= 1 + objectWillChange.send() + } } } diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift index c8a6a30..c4a109a 100644 --- a/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift @@ -9,6 +9,7 @@ import SwiftUI struct QRScannerView: UIViewControllerRepresentable { @Binding var scannedCode: String? + var isActive: Bool = true // ✅ 추가: 카메라 활성화 상태 func makeUIViewController(context: Context) -> QRScannerViewController { let controller = QRScannerViewController() @@ -17,12 +18,20 @@ struct QRScannerView: UIViewControllerRepresentable { } // func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} +// func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) { +// // ✅ scannedCode가 nil이면 다시 스캔 시작 +// if scannedCode == nil { +// uiViewController.startScanning() +// } +// } + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) { - // ✅ scannedCode가 nil이면 다시 스캔 시작 - if scannedCode == nil { - uiViewController.startScanning() + if isActive { + uiViewController.startSession() // ✅ 바텀시트 닫혔을 때 다시 스캔 시작 + } else { + uiViewController.stopSession() // ✅ 바텀시트 열렸을 때 스캔 일시정지 + } } - } func makeCoordinator() -> Coordinator { Coordinator(self) diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift index e36d7b4..b3d6321 100644 --- a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift @@ -76,6 +76,19 @@ final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputOb } } + func startSession() { + if !captureSession.isRunning { + captureSession.startRunning() + } + } + + func stopSession() { + if captureSession.isRunning { + captureSession.stopRunning() + } + } + + // ✅ QR 감지 시 호출 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, From 2d5a3daaef7fd4f1f07e76616a80c6c8879c9db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Tue, 4 Nov 2025 16:15:48 +0900 Subject: [PATCH 12/22] =?UTF-8?q?[FEAT]=20=EB=B6=80=ED=92=88=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inventory/ui/OutgoingScanView.swift | 2 + .../inventory/ui/UsedPartListSheetView.swift | 334 +++++++++++++----- .../app/feature/orders/ui/OrderInfoView.swift | 2 +- 3 files changed, 257 insertions(+), 81 deletions(-) diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index a4d1105..25d8f04 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -131,6 +131,8 @@ struct OutgoingScanView: View { resetScanState() // QR 다시 활성화 } ) + .presentationDetents([.fraction(0.80)]) // 시트 높이 80% + .presentationCornerRadius(28) // ✅ 모서리 곡률 .environmentObject(partStore) } .navigationTitle("부품 사용 처리") diff --git a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift index f81f320..ca55bd2 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -21,102 +21,276 @@ struct UsedPartListSheetView: View { var onRescan: (() -> Void)? // ✅ 다시 스캔 콜백 추가 var body: some View { - NavigationStack { - VStack { - if partStore.parts.isEmpty { - VStack(spacing: 12) { - Image(systemName: "cube.box") - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - .foregroundColor(.gray.opacity(0.6)) - Text("추가된 부품이 없습니다.") - .foregroundColor(.gray) +// NavigationStack { + VStack (alignment: .center){ + // ✅ 상단 헤더 + ZStack { + Text("사용할 부품") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.black) + + HStack { + Spacer() + Button("전체 삭제") { + partStore.clear() + } + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.red) + .padding(.trailing, 20) } - .padding(.top, 100) - } else { - ScrollView { + } + .padding(.vertical, 26) + .background(Color.white) + + + // ✅ 내용 영역 + ScrollView { + if partStore.parts.isEmpty { + // 부품 목록 전체 삭제의 경우 + VStack(spacing: 12) { + Image(systemName: "cube.box") + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .foregroundColor(.gray.opacity(0.6)) + Text("추가된 부품이 없습니다.") + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, minHeight: 200) + } else { LazyVStack { - ForEach($partStore.parts) { $part in // ✅ 바인딩으로 변경 ($ 붙임) - HStack(spacing: 12) { - AsyncImage(url: URL(string: part.image)) { image in - image.resizable().scaledToFill() - } placeholder: { - Color.gray.opacity(0.2) + ForEach( + $partStore.parts + ) { $part in // ✅ 바인딩으로 변경 ($ 붙임) + VStack(alignment: .leading, spacing: 6) { + Text(part.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: part.image) + ) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .clipShape( + RoundedRectangle(cornerRadius: 10) + ) + + VStack( + alignment: .leading, + spacing: 6 + ) { + Text(part.korName) + .font( + .system( + size: 13, + weight: .bold + ) + ) + .foregroundColor(.black) + .lineLimit(2) + Text( + "\((part.trim)) / \((part.model))" + ) + .font(.system(size: 12)) + .foregroundColor(.black) + .lineLimit(1) + Text("\(part.price)원") + .font( + .system( + size: 15, + weight: .semibold + ) + ) + .foregroundColor(.black) + } + + Spacer() + + // ✅ 수량 조절 버튼 (디자인 개선) + HStack(spacing: 10) { + Button { + if part.quantity > 1 { + part.quantity -= 1 + } + } label: { + Image(systemName: "minus") + .font( + .system( + size: 14, + weight: .regular + ) + ) + .frame( + width: 13, + height: 13 + ) + .foregroundColor(.black) + } + + Text("\(part.quantity)") + .font( + .system( + size: 14, + weight: .medium + ) + ) + .frame(width: 19) + + Button { + part.quantity += 1 + } label: { + Image(systemName: "plus") + .font( + .system( + size: 14, + weight: .regular + ) + ) + .frame( + width: 13, + height: 13 + ) + .foregroundColor(.black) + } + } + .padding(.vertical, 6) + .padding( + .horizontal, + 8 + ) // 🔹 살짝 늘려서 버튼 안이 넓어 보이게 + .background(Color.white) + .cornerRadius(10) + .overlay( + // ✅ 테두리 추가 + RoundedRectangle(cornerRadius: 10) + .stroke( + Color.LightBlue03, + lineWidth: 1 + ) + ) + .shadow( + color: .black.opacity(0.15), + radius: 4, + x: 0, + y: 4 + ) } - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text(part.korName) - .font(.headline) - Text(part.model) - .font(.subheadline) - .foregroundColor(.secondary) - Text(part.categoryName) - .font(.caption) - .foregroundColor(.gray) - } - - Spacer() - - // ✅ 수량 조절 버튼 - HStack { - Button("-") { if part.quantity > 1 { part.quantity -= 1 } } - Text("\(part.quantity)") - Button("+") { part.quantity += 1 } - } - .padding(.trailing, 4) } - .padding(.vertical, 4) + .padding() + .background(Color.white) + .cornerRadius(14) + .shadow( + color: .black.opacity(0.05), + radius: 4, + x: 0, + y: 4 + ) + } .onDelete { indexSet in partStore.parts.remove(atOffsets: indexSet) } } .listStyle(.plain) + } } - - // 🔹 다시 스캔 버튼 - Button { - onRescan?() - } label: { - Text("다시 스캔") - .font(.headline) - .foregroundColor(.blue) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue.opacity(0.1)) - .cornerRadius(10) - } - .padding(.bottom, 16) + .padding(7) + .frame(maxHeight: .infinity) + + HStack{ + // 🔹 다시 스캔 버튼 + Button { + onRescan?() + } label: { + Text("부품 추가") + .font(.headline) + .foregroundColor(.Primary) + .frame(maxWidth: .infinity) + .padding() + .background(Color.white) + .cornerRadius(9999) + .background( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.Primary, lineWidth: 2) + ) + } + .padding(.bottom, 16) - // ✅ 사용 처리 버튼 - Button { - onUseParts?() - } label: { - Text("사용 처리") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(partStore.parts.isEmpty ? Color.gray : Color.blue) - .cornerRadius(10) - .padding(.horizontal) - } - .disabled(partStore.parts.isEmpty) - .padding(.bottom, 16) - } - .navigationTitle("사용할 부품 목록") - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("전체삭제") { - partStore.clear() + // ✅ 사용 처리 버튼 + Button { + onUseParts?() + } label: { + Text("사용 처리") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background( + partStore.parts.isEmpty ? Color.gray : Color.Primary + ) + .cornerRadius(9999) } - .foregroundColor(.red) + .disabled(partStore.parts.isEmpty) + .padding(.bottom, 16) } + .padding(.horizontal) + } + } +} + +// MARK: - 프리뷰 (안전 버전) + +@MainActor +struct UsedPartListSheetView_Previews: PreviewProvider { + static var previewStore: PartStore = { + let store = PartStore() + store.parts = [ + PartDetail( + id: 1, + price: 10000, + image: "https://via.placeholder.com/150", + trim: "준준형/소형", + model: "아반떼MD", + korName: "액츄에이터 - 템퍼러처 도어", + categoryName: "전기/램프", + quantity: 2 + ), + PartDetail( + id: 2, + price: 25000, + image: "https://via.placeholder.com/150", + trim: "준준형/소형", + model: "아반떼 MD", + korName: "스위치 어셈블리 - 도어", + categoryName: "전기/램프", + quantity: 1 + ) + ] + return store + }() + + static var previews: some View { + NavigationStack { + UsedPartListSheetView( + onUseParts: { print("✅ 사용 처리 버튼 눌림") }, + onRescan: { print("🔄 다시 스캔 버튼 눌림") } + ) + .environmentObject(previewStore) } } } + + diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift index f32ac74..430e3b3 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift @@ -91,7 +91,7 @@ struct OrderInfoView: View { // ✅ 충전 bottom sheet 연결 .sheet(isPresented: $depositViewModel.showChargeSheet) { DepositChargeView(viewModel: depositViewModel) - .presentationDetents([.fraction(0.80)]) // 시트 높이 85% + .presentationDetents([.fraction(0.80)]) // 시트 높이 80% // .presentationDragIndicator(.visible) } From 9e1805dbb621b091b1aecc31fadb7d680d0cfc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Tue, 4 Nov 2025 16:50:44 +0900 Subject: [PATCH 13/22] =?UTF-8?q?[REFAC]=20=EC=9E=85=EC=B6=9C=EA=B3=A0=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=8C=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/dashboard/data/HistoryApi.swift | 9 -- .../dashboard/ui/ReleaseDetailView.swift | 27 ++---- .../inventory/ui/UsedPartListSheetView.swift | 93 ++++--------------- 3 files changed, 22 insertions(+), 107 deletions(-) diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift index 8882e6b..9c63204 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -78,15 +78,6 @@ struct PaymentTransactionPageData: Decodable { let first: Bool } -//struct PaymentTransactionItem: Decodable, Identifiable { -// var id: UUID { UUID() } // 서버에서 id 제공 안하므로 로컬에서 생성 -// let transactionType: String // "CHARGE" or "PAY" -// let transactionTime: String -// let totalAmount: Int -// let orderId: Int -// let balance: Int -//} - struct PaymentTransactionItem: Decodable, Identifiable { var id: UUID { UUID() } // 서버에서 id 제공 안하므로 로컬 생성 let transactionType: String // "CHARGE" or "PAY" diff --git a/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift index af44590..d1cd2ed 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/ReleaseDetailView.swift @@ -36,9 +36,6 @@ struct ReleaseDetailView: View { .navigationTitle("출고 상세") .navigationBarTitleDisplayMode(.inline) } -// func formatDate(_ iso: String) -> String { -// String(iso.prefix(10)).replacingOccurrences(of: "-", with: ".") -// } } struct ReleasePartCard: View { @@ -109,24 +106,21 @@ struct ReleasePartCard: View { )) } + func formattedDate3(_ timestamp: String) -> String { - // 가능한 입력 포맷들 (서버별 변형 대응) let inputFormats = [ "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss", - "yyyy-MM-dd'T'HH:mm:ssZ", - "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + "yyyy-MM-dd'T'HH:mm:ss" ] let trimmed = timestamp.trimmingCharacters(in: .whitespacesAndNewlines) let parser = DateFormatter() parser.locale = Locale(identifier: "en_US_POSIX") - parser.timeZone = TimeZone(abbreviation: "UTC") + parser.timeZone = TimeZone(identifier: "Asia/Seoul") // ✅ 서버 시간 기준으로 맞춤 var date: Date? = nil - for format in inputFormats { parser.dateFormat = format if let parsed = parser.date(from: trimmed) { @@ -135,22 +129,13 @@ func formattedDate3(_ timestamp: String) -> String { } } - // ISO8601 fallback - if date == nil { - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - date = iso.date(from: trimmed) ?? ISO8601DateFormatter().date(from: trimmed) - } - - guard let finalDate = date else { - return timestamp // 파싱 실패 시 원본 반환 - } + guard let finalDate = date else { return timestamp } + // 출력도 한국시간으로 let output = DateFormatter() output.locale = Locale(identifier: "ko_KR") - output.timeZone = TimeZone.current + output.timeZone = TimeZone(identifier: "Asia/Seoul") output.dateFormat = "yyyy.MM.dd HH:mm:ss" return output.string(from: finalDate) } - diff --git a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift index ca55bd2..6b14cc2 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -21,7 +21,6 @@ struct UsedPartListSheetView: View { var onRescan: (() -> Void)? // ✅ 다시 스캔 콜백 추가 var body: some View { -// NavigationStack { VStack (alignment: .center){ // ✅ 상단 헤더 ZStack { @@ -31,9 +30,7 @@ struct UsedPartListSheetView: View { HStack { Spacer() - Button("전체 삭제") { - partStore.clear() - } + Button("전체 삭제") {partStore.clear()} .font(.system(size: 14, weight: .medium)) .foregroundColor(.red) .padding(.trailing, 20) @@ -64,9 +61,7 @@ struct UsedPartListSheetView: View { ) { $part in // ✅ 바인딩으로 변경 ($ 붙임) VStack(alignment: .leading, spacing: 6) { Text(part.categoryName) - .font( - .system(size: 12, weight: .semibold) - ) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(.black) Divider() @@ -86,32 +81,17 @@ struct UsedPartListSheetView: View { RoundedRectangle(cornerRadius: 10) ) - VStack( - alignment: .leading, - spacing: 6 - ) { + VStack(alignment: .leading,spacing: 6) { Text(part.korName) - .font( - .system( - size: 13, - weight: .bold - ) - ) + .font( .system(size: 13, weight: .bold)) .foregroundColor(.black) .lineLimit(2) - Text( - "\((part.trim)) / \((part.model))" - ) + Text("\((part.trim)) / \((part.model))") .font(.system(size: 12)) .foregroundColor(.black) .lineLimit(1) Text("\(part.price)원") - .font( - .system( - size: 15, - weight: .semibold - ) - ) + .font(.system(size: 15, weight: .semibold)) .foregroundColor(.black) } @@ -125,77 +105,37 @@ struct UsedPartListSheetView: View { } } label: { Image(systemName: "minus") - .font( - .system( - size: 14, - weight: .regular - ) - ) - .frame( - width: 13, - height: 13 - ) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) .foregroundColor(.black) } - Text("\(part.quantity)") - .font( - .system( - size: 14, - weight: .medium - ) - ) + .font(.system(size: 14,weight: .medium)) .frame(width: 19) - Button { part.quantity += 1 } label: { Image(systemName: "plus") - .font( - .system( - size: 14, - weight: .regular - ) - ) - .frame( - width: 13, - height: 13 - ) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) .foregroundColor(.black) } } .padding(.vertical, 6) - .padding( - .horizontal, - 8 - ) // 🔹 살짝 늘려서 버튼 안이 넓어 보이게 + .padding(.horizontal, 8) .background(Color.white) .cornerRadius(10) - .overlay( - // ✅ 테두리 추가 + .overlay( // ✅ 테두리 추가 RoundedRectangle(cornerRadius: 10) - .stroke( - Color.LightBlue03, - lineWidth: 1 - ) - ) - .shadow( - color: .black.opacity(0.15), - radius: 4, - x: 0, - y: 4 + .stroke( Color.LightBlue03, lineWidth: 1) ) + .shadow(color: .black.opacity(0.15), radius: 4, x: 0, y: 4 ) } } .padding() .background(Color.white) .cornerRadius(14) - .shadow( - color: .black.opacity(0.05), - radius: 4, - x: 0, - y: 4 - ) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) } .onDelete { indexSet in @@ -252,7 +192,6 @@ struct UsedPartListSheetView: View { } // MARK: - 프리뷰 (안전 버전) - @MainActor struct UsedPartListSheetView_Previews: PreviewProvider { static var previewStore: PartStore = { From 1b8843cba40d15e12c1fdeb039b8143bd295a622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Tue, 4 Nov 2025 23:24:54 +0900 Subject: [PATCH 14/22] =?UTF-8?q?[REFAC]=20=EB=B2=84=ED=8A=BC=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20UI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/AlertModal.swift | 140 ++++++++++++++++++ .../app/core/components/CartCard.swift | 58 +++++--- .../core/components/CustomButtonStyle.swift | 50 +++++++ .../components/OrderRequestCardView.swift | 108 +++++++++++--- .../app/feature/auth/ui/HomeView.swift | 113 ++++++++------ .../inventory/ui/UsedPartListSheetView.swift | 4 +- .../app/feature/orders/ui/OrderInfoView.swift | 98 ++++++++++-- .../app/feature/orders/ui/OrderView.swift | 14 +- .../SuccessIllust.imageset/Contents.json | 12 ++ .../SuccessIllust.imageset/SuccessIllust.svg | 14 ++ .../add_shopping_cart.imageset/Contents.json | 23 +++ .../add_shopping_cart@1x.png | Bin 0 -> 458 bytes .../add_shopping_cart@2x.png | Bin 0 -> 752 bytes .../add_shopping_cart@3x.png | Bin 0 -> 1036 bytes .../shoppingcart.imageset/Contents.json | 12 ++ .../shoppingcart.imageset/shoppingcart.svg | 8 + StockMate/StockMate/resources/Color.swift | 3 + 17 files changed, 555 insertions(+), 102 deletions(-) create mode 100644 StockMate/StockMate/app/core/components/AlertModal.swift create mode 100644 StockMate/StockMate/app/core/components/CustomButtonStyle.swift create mode 100644 StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/SuccessIllust.svg create mode 100644 StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@1x.png create mode 100644 StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@2x.png create mode 100644 StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@3x.png create mode 100644 StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/Contents.json create mode 100644 StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/shoppingcart.svg diff --git a/StockMate/StockMate/app/core/components/AlertModal.swift b/StockMate/StockMate/app/core/components/AlertModal.swift new file mode 100644 index 0000000..72d1e1b --- /dev/null +++ b/StockMate/StockMate/app/core/components/AlertModal.swift @@ -0,0 +1,140 @@ +// +// AlertModal.swift +// StockMate +// +// Created by Admin on 11/4/25. +// + + +import SwiftUI + +struct AlertModal: View { + var icon: Image? = nil // ✅ 아이콘 없을 수도 있음 + var title: String + var message: String? = nil + + var primaryButtonTitle: String + var primaryAction: () -> Void + + var secondaryButtonTitle: String? = nil + var secondaryAction: (() -> Void)? = nil + + var buttonLayout: ButtonLayout = .vertical // ✅ horizontal / vertical + + enum ButtonLayout { + case vertical + case horizontal + } + + var body: some View { + VStack(spacing: 15) { + if let icon = icon { + icon + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + .padding(.top, 6) + } + + Text(title) + .font(.system(size: 18, weight: .bold)) + .multilineTextAlignment(.center) + .padding(.top, 10) + + if let message = message { + Text(message) + .font(.system(size: 14, weight: .regular)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.bottom, 6) + } + + if buttonLayout == .vertical { + VStack(spacing: 10) { + if let secondary = secondaryButtonTitle, let secondaryAction = secondaryAction { + Button(secondary, action: secondaryAction) + .buttonStyle(CustomButtonStyle(type: .outlined(.Primary))) + } + Button(primaryButtonTitle, action: primaryAction) + .buttonStyle(CustomButtonStyle(type: .filled(Color.Primary))) + } + } else { + HStack(spacing: 10) { + if let secondary = secondaryButtonTitle, let secondaryAction = secondaryAction { + Button(secondary, action: secondaryAction) + .buttonStyle(CustomButtonStyle(type: .outlined(.Primary))) + } + Button(primaryButtonTitle, action: primaryAction) + .buttonStyle(CustomButtonStyle(type: .filled(Color.Primary))) + } + } + } + .padding(20) + .frame(maxWidth: 300) + .background(Color.white) + .cornerRadius(32) + .shadow(radius: 8) + } +} + +import SwiftUI + +#Preview { + ScrollView{ + VStack(spacing: 40) { + // ✅ 1. 체크 아이콘 + 버튼 1개 + AlertModal( + icon: Image("SuccessIllust"), + title: "등록 완료!", + message: "입고 부품 등록이 완료되었습니다.", + primaryButtonTitle: "확인", + primaryAction: {} + ) + AlertModal( + icon: Image("SuccessIllust"), + title: "출고 완료!", + message: "사용 처리가 완료되었습니다.", + primaryButtonTitle: "확인", + primaryAction: {} + ) + + + + // ✅ 2. 아이콘 없이 버튼 2개 (가로) + AlertModal( + title: "주문 취소", + message: "주문을 취소하시겠습니까?", + primaryButtonTitle: "예", + primaryAction: {}, + secondaryButtonTitle: "아니오", + secondaryAction: {}, + buttonLayout: .horizontal + ) + AlertModal( + title: "로그아웃", + message: "로그아웃 하시겠습니까?", + primaryButtonTitle: "로그아웃", + primaryAction: {}, + secondaryButtonTitle: "취소", + secondaryAction: {}, + buttonLayout: .horizontal + ) + + + // ✅ 3. 주문완료 + AlertModal( + icon: Image("SuccessIllust"), + title: "주문완료!", + message: "해당 부품 주문이 완료되었습니다.", + primaryButtonTitle: "주문상세", + primaryAction: {}, + secondaryButtonTitle: "홈으로", + secondaryAction: {}, + buttonLayout: .vertical + ) + } + .padding() + .background(Color.gray.opacity(0.1)) + } + +} diff --git a/StockMate/StockMate/app/core/components/CartCard.swift b/StockMate/StockMate/app/core/components/CartCard.swift index 772629f..556483f 100644 --- a/StockMate/StockMate/app/core/components/CartCard.swift +++ b/StockMate/StockMate/app/core/components/CartCard.swift @@ -55,61 +55,83 @@ struct CartCard: View { if quantity == 0 { if let onAddToCart = onAddToCart { Button(action: onAddToCart) { - Image(systemName: "cart.badge.plus") - .font(.system(size: 18)) - .foregroundColor(.Primary) +// Image(systemName: "cart.badge.plus") +// .font(.system(size: 18)) +// .foregroundColor(.Primary) +// .padding(10) +// .background(Color.Primary.opacity(0.1)) +// .clipShape(Circle()) + Image("add_shopping_cart") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .padding(10) - .background(Color.Primary.opacity(0.1)) + .background(Color.white) .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 4) } } } else if quantity == 1 { HStack(spacing: 10) { Button(action: onRemoveFromCart) { Image(systemName: "trash") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.red) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } Text("1") - .font(.system(size: 15, weight: .semibold)) - .frame(width: 24) + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) Button(action: onIncrease) { Image(systemName: "plus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.Primary) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.white) .cornerRadius(10) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + .overlay( // ✅ 테두리 추가 + RoundedRectangle(cornerRadius: 10) + .stroke( Color.LightBlue03, lineWidth: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) } else { HStack(spacing: 10) { Button(action: onDecrease) { Image(systemName: "minus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.gray) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } Text("\(quantity)") - .font(.system(size: 15, weight: .semibold)) - .frame(width: 24) + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) Button(action: onIncrease) { Image(systemName: "plus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.Primary) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.white) .cornerRadius(10) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + .overlay( // ✅ 테두리 추가 + RoundedRectangle(cornerRadius: 10) + .stroke( Color.LightBlue03, lineWidth: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) } } } diff --git a/StockMate/StockMate/app/core/components/CustomButtonStyle.swift b/StockMate/StockMate/app/core/components/CustomButtonStyle.swift new file mode 100644 index 0000000..fe42295 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CustomButtonStyle.swift @@ -0,0 +1,50 @@ +// +// CustomButtonStyle.swift +// StockMate +// +// Created by Admin on 11/4/25. +// + + +import SwiftUI + +struct CustomButtonStyle: ButtonStyle { + enum StyleType { + case filled(Color) + case outlined(Color) + } + + var type: StyleType + var height: CGFloat = 52 + var cornerRadius: CGFloat = 9999 + var fontSize: CGFloat = 16 + var fontWeight: Font.Weight = .semibold + + func makeBody(configuration: Configuration) -> some View { + switch type { + case .filled(let color): + configuration.label + .frame(maxWidth: .infinity, minHeight: height) + .background(color.opacity(configuration.isPressed ? 0.8 : 1)) + .foregroundColor(.white) + .cornerRadius(cornerRadius) + .font(.system(size: fontSize, weight: fontWeight)) +// .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + + case .outlined(let color): + configuration.label + .frame(maxWidth: .infinity, minHeight: height) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(color, lineWidth: 1.5) + ) + .foregroundColor(color) + .font(.system(size: fontSize, weight: fontWeight)) + .background(Color.white) + .cornerRadius(cornerRadius) +// .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } + } +} diff --git a/StockMate/StockMate/app/core/components/OrderRequestCardView.swift b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift index aa29bef..a328a43 100644 --- a/StockMate/StockMate/app/core/components/OrderRequestCardView.swift +++ b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift @@ -52,61 +52,80 @@ struct OrderRequestCardView: View { // 🪄 수량에 따른 3단계 분기 if quantity == 0 { Button(action: onAddToCart) { - Image(systemName: "cart.badge.plus") - .font(.system(size: 18)) - .foregroundColor(.Primary) + Image("add_shopping_cart") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .padding(10) - .background(Color.Primary.opacity(0.1)) + .background(Color.white) .clipShape(Circle()) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) + } } else if quantity == 1 { HStack(spacing: 10) { Button(action: onRemoveFromCart) { Image(systemName: "trash") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.red) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } Text("1") - .font(.system(size: 15, weight: .semibold)) - .frame(width: 24) + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) Button(action: onIncrease) { Image(systemName: "plus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.Primary) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.white) .cornerRadius(10) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + .overlay( // ✅ 테두리 추가 + RoundedRectangle(cornerRadius: 10) + .stroke( Color.LightBlue03, lineWidth: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) + } else { HStack(spacing: 10) { Button(action: onDecrease) { Image(systemName: "minus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.gray) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } Text("\(quantity)") - .font(.system(size: 15, weight: .semibold)) - .frame(width: 24) + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) Button(action: onIncrease) { Image(systemName: "plus") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.Primary) + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) } } .padding(.vertical, 6) .padding(.horizontal, 10) .background(Color.white) .cornerRadius(10) - .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + .overlay( // ✅ 테두리 추가 + RoundedRectangle(cornerRadius: 10) + .stroke( Color.LightBlue03, lineWidth: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) + } } } @@ -116,3 +135,56 @@ struct OrderRequestCardView: View { .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) } } + +#Preview { + let sampleItem = InventoryItem( + id: 1, + name: "Engine Oil Filter", + price: 18000, + image: "https://picsum.photos/200", + trim: "1.6 Turbo", + model: "SM-230", + category: 3, + korName: "엔진 오일 필터", + engName: "Engine Oil Filter", + categoryName: "엔진 부품", + stock: 42, + amount: 3, + limitAmount: 5, + isLack: true + ) + + VStack(spacing: 20) { + // 수량 0 (아직 카트에 안 담김) + OrderRequestCardView( + item: sampleItem, + quantity: 0, + onIncrease: {}, + onDecrease: {}, + onAddToCart: {}, + onRemoveFromCart: {} + ) + + // 수량 1 (카트에 하나 있음) + OrderRequestCardView( + item: sampleItem, + quantity: 1, + onIncrease: {}, + onDecrease: {}, + onAddToCart: {}, + onRemoveFromCart: {} + ) + + // 수량 3 (여러 개 담긴 상태) + OrderRequestCardView( + item: sampleItem, + quantity: 3, + onIncrease: {}, + onDecrease: {}, + onAddToCart: {}, + onRemoveFromCart: {} + ) + } + .padding() + .background(Color(uiColor: .systemGray6)) +} diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index ddd982c..615d4f4 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -62,7 +62,7 @@ struct HomeView: View { .cornerRadius(9999) .overlay( RoundedRectangle(cornerRadius: 9999) - .stroke(Color.gray.opacity(0.4), lineWidth: 1) + .stroke(Color.GrayMordern400, lineWidth: 1) ) .padding(.horizontal) } @@ -73,17 +73,19 @@ struct HomeView: View { // 도넛 차트 섹션 VStack(alignment: .leading, spacing: 18) { Text("지난달 카테고리 별 지출") - .font(.headline) + .font(.system(size: 15, weight: .semibold)) .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) // ✅ 항상 왼쪽 정렬 HStack { if dashboardViewModel.isLoading { ProgressView("불러오는 중...") - .frame(height: 130) + .frame(height: 155) } else if dashboardViewModel.categorySpendings.isEmpty { Text("지난달 지출 내역이 없습니다.") .foregroundColor(.gray) - .frame(height: 150) + .frame(maxWidth: .infinity) + .frame(height: 155, alignment: .center) } else { DonutChartView(data: dashboardViewModel.categorySpendings) .frame(height: 155) @@ -103,25 +105,32 @@ struct HomeView: View { // 막대그래프 섹션 VStack(alignment: .leading, spacing: 8) { Text("월간 지출 현황") - .font(.headline) + .font(.system(size: 15, weight: .semibold)) .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) // ✅ 항상 왼쪽 정렬 - if dashboardViewModel.isLoading { - ProgressView("데이터 불러오는 중...") - .frame(height: 150) - } else if dashboardViewModel.monthlySpendings.isEmpty { - Text("최근 지출 내역이 없습니다.") - .foregroundColor(.gray) - .frame(height: 150) - } else { - BarChartView( - values: dashboardViewModel.spendingRatios, - labels: dashboardViewModel.monthLabels, - amounts: dashboardViewModel.monthlySpendings.map { $0.totalAmount }, selectedMonth: $selectedMonth - ) - .frame(height: 220) - .background(Color.white) - .cornerRadius(16) + ZStack { // ✅ 크기 고정용 컨테이너 + RoundedRectangle(cornerRadius: 16) + .fill(Color.white) + .frame(height: 220) // ✅ 일정 높이 고정 + if dashboardViewModel.isLoading { + ProgressView("데이터 불러오는 중...") + .frame(height: 220) + } else if dashboardViewModel.monthlySpendings.isEmpty { + Text("최근 지출 내역이 없습니다.") + .foregroundColor(.gray) + .frame(height: 220) + } else { + BarChartView( + values: dashboardViewModel.spendingRatios, + labels: dashboardViewModel.monthLabels, + amounts: dashboardViewModel.monthlySpendings.map { $0.totalAmount }, selectedMonth: $selectedMonth + ) + .padding() + // .frame(height: 220) + // .background(Color.white) + // .cornerRadius(16) + } } } .padding() @@ -154,19 +163,33 @@ struct HomeView: View { private var lackStockSection: some View { VStack(alignment: .leading, spacing: 8) { Text("재고 부족 조회") - .font(.headline) + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) // ✅ 항상 왼쪽 정렬 유지 HStack(spacing: 13) { - ForEach(inventoryViewModel.lackCounts, id: \.id) { item in - NavigationLink { - LackListView(selectedCategory: item.categoryName) - } label: { + if inventoryViewModel.lackCounts.isEmpty { + // ✅ 데이터가 없을 때도 공간 확보 + ForEach(0..<5) { _ in StatusItem( - title: item.categoryName, - count: item.count, - color: colorForCategory(item.categoryName), - icon: iconForCategory(item.categoryName) + title: "-", + count: 0, + color: .gray.opacity(0.1), + icon: "questionmark" ) } + .redacted(reason: .placeholder) // 로딩 중 효과 (선택사항) + } else { + ForEach(inventoryViewModel.lackCounts, id: \.id) { item in + NavigationLink { + LackListView(selectedCategory: item.categoryName) + } label: { + StatusItem( + title: item.categoryName, + count: item.count, + color: colorForCategory(item.categoryName), + icon: iconForCategory(item.categoryName) + ) + } + } } } .padding(.vertical, 4) @@ -223,7 +246,7 @@ struct StatusItem: View { .clipShape(RoundedRectangle(cornerRadius: 100)) Text(title) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(.black) .padding(.top, 5) .lineLimit(1) @@ -242,17 +265,17 @@ struct StatusItem: View { // HomeView() //} -//#Preview { -// let dashboardVM = DashboardViewModel() -// dashboardVM.categorySpendings = [ -// CategorySpending(categoryName: "전기/램프", totalAmount: 450000), -// CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), -// CategorySpending(categoryName: "하체/바디", totalAmount: 150000), -// CategorySpending(categoryName: "내장/외장", totalAmount: 100000), -// CategorySpending(categoryName: "기타소모품", totalAmount: 50000) -// ] -// -// return HomeView() -// .environmentObject(AuthViewModel()) -// .environmentObject(dashboardVM) // ✅ 이제 진짜 연결됨! -//} +#Preview { + let dashboardVM = DashboardViewModel() + dashboardVM.categorySpendings = [ + CategorySpending(categoryName: "전기/램프", totalAmount: 450000), + CategorySpending(categoryName: "엔진/미션", totalAmount: 300000), + CategorySpending(categoryName: "하체/바디", totalAmount: 150000), + CategorySpending(categoryName: "내장/외장", totalAmount: 100000), + CategorySpending(categoryName: "기타소모품", totalAmount: 50000) + ] + + return HomeView() + .environmentObject(AuthViewModel()) + .environmentObject(dashboardVM) // ✅ 이제 진짜 연결됨! +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift index 6b14cc2..5b4f73c 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -110,8 +110,8 @@ struct UsedPartListSheetView: View { .foregroundColor(.black) } Text("\(part.quantity)") - .font(.system(size: 14,weight: .medium)) - .frame(width: 19) + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) Button { part.quantity += 1 } label: { diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift index 430e3b3..9c6d8f4 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift @@ -29,15 +29,31 @@ struct OrderInfoView: View { @State private var navigateToSuccessPage = false + // ✅ 모달 관련 상태 + @State private var showOrderSuccessModal = false + @State private var navigateToOrderDetail = false + @State private var navigateToHome = false + private var destinationView: some View { - Group { - if let id = orderViewModel.createdOrderId { - OrderDetailView(orderId: id, orderViewModel: orderViewModel) - } else { - EmptyView() - } - } + Group { + if navigateToOrderDetail, let id = orderViewModel.createdOrderId { + OrderDetailView(orderId: id, orderViewModel: orderViewModel) + } else if navigateToHome { + HomeView() + } else { + EmptyView() + } + } } +// private var destinationView: some View { +// Group { +// if let id = orderViewModel.createdOrderId { +// OrderDetailView(orderId: id, orderViewModel: orderViewModel) +// } else { +// EmptyView() +// } +// } +// } func formattedShippingDate() -> String { let formatter = DateFormatter() @@ -83,8 +99,12 @@ struct OrderInfoView: View { .onChange(of: orderViewModel.isOrderSuccess) { success in if success { Task { + // 1) 서버에 반영된 장바구니를 먼저 비운다 (await) await cartViewModel.clearCart() - navigateToSuccessPage = true +// navigateToSuccessPage = true + // 2) cart가 비워진 후에 모달을 띄운다 + // (모달을 띄우기 전에 createdOrderId는 orderViewModel에 이미 세팅되어 있어야 함) + showOrderSuccessModal = true } } } @@ -92,8 +112,67 @@ struct OrderInfoView: View { .sheet(isPresented: $depositViewModel.showChargeSheet) { DepositChargeView(viewModel: depositViewModel) .presentationDetents([.fraction(0.80)]) // 시트 높이 80% -// .presentationDragIndicator(.visible) } + // 모달 오버레이 (body 안) + .overlay { + if showOrderSuccessModal { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + .onTapGesture { + // 배경 탭으로도 모달 닫을 수 있게 하려면 uncomment + // showOrderSuccessModal = false + } + + AlertModal( + icon: Image("SuccessIllust"), + title: "주문완료!", + message: "해당 부품 주문이 완료되었습니다.", + primaryButtonTitle: "주문상세", + primaryAction: { + // 1) 모달 닫기 + showOrderSuccessModal = false + + // 2) 네비게이션 트리거 -> OrderDetail 로 이동 + // orderViewModel.createdOrderId 가 있어야 함 + navigateToOrderDetail = true + }, + secondaryButtonTitle: "홈으로", + secondaryAction: { + // 모달 닫고 홈으로 + showOrderSuccessModal = false + navigateToHome = true + }, + buttonLayout: .vertical + ) + .transition(.scale) + .padding(.horizontal, 20) + } + .animation(.easeInOut, value: showOrderSuccessModal) + } + } + + // 네비게이션 실행을 위한 숨은 링크 (body 밖 어디든) + .background( + Group { + // OrderDetail 우선 (OrderDetail은 createdOrderId 를 필요로 함) + NavigationLink(destination: + Group { + if let id = orderViewModel.createdOrderId { + OrderDetailView(orderId: id, orderViewModel: orderViewModel) + } else { + EmptyView() + } + }, + isActive: $navigateToOrderDetail) { + EmptyView() + } + + NavigationLink(destination: HomeView(), isActive: $navigateToHome) { + EmptyView() + } + } + ) + } @@ -238,7 +317,6 @@ extension OrderInfoView { .frame(maxWidth: .infinity) } - private var shippingDateSection: some View { VStack(alignment: .leading) { Text("배송 요청일") diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderView.swift index c238a30..3fa5db5 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderView.swift @@ -31,10 +31,10 @@ struct OrderView: View { // 🔍 검색창 NavigationLink(destination: - OrderRequestSearchView( - cartViewModel: cartViewModel - //inventoryViewModel: inventoryViewModel - ) + OrderRequestSearchView( + cartViewModel: cartViewModel + //inventoryViewModel: inventoryViewModel + ) ) { HStack { Image(systemName: "magnifyingglass") @@ -49,7 +49,7 @@ struct OrderView: View { .cornerRadius(9999) .overlay( RoundedRectangle(cornerRadius: 9999) - .stroke(Color.gray.opacity(0.4), lineWidth: 1) + .stroke(Color.GrayMordern400, lineWidth: 1) ) .padding(.horizontal) } @@ -126,7 +126,3 @@ struct OrderView: View { } } -// -//#Preview { -// OrderView() -//} diff --git a/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/Contents.json new file mode 100644 index 0000000..4890788 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SuccessIllust.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/SuccessIllust.svg b/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/SuccessIllust.svg new file mode 100644 index 0000000..60b221d --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/SuccessIllust.imageset/SuccessIllust.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/Contents.json new file mode 100644 index 0000000..566c0a1 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "add_shopping_cart@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "add_shopping_cart@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "add_shopping_cart@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@1x.png b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..a9d1e53d74ce3a4868a7ee959395842a705f0f8b GIT binary patch literal 458 zcmV;*0X6=KP)=F9Wf)?3vcMSwk zlKwaeWZ~`G_ukF`{8t?W3%BRbFWPWJz4}4&NCdlxc=j^rh6R`tS&YzXBV)1X2dP6q z!`H#a(Y`-FMaB=2$R+>_5MvVgB)ZvpP*}(0VR+S@vV#1hi24xVofrwmAZQ|=H?0-I;T{zrEMspC{@Xx7W-M1tTqv{W5*+%Q>h7qp32RL=O&i| z&CF%?(ZVCdZPUcN>i=byJ3#10!%Y{ew_Ppiat9HtEQet&65}9MTiXVEUTXLJlakUX~$h{Ra6OuK1<`m)wOS_cC@*Jq)JvtP=}_L8T!AdFFT-HDqR=X6hI zb}2tKR%;b!O!f(ioy=P#@*Jz*O)@(+D89U-eB;z{@FAP{vQ{K#Q! z;cg1!t@snfm+SR*oa+QW4nct_tz|}s??8=!JYEW(I~w2+u@*>Yzga&wCSfA?u`#N| zgttWWz31h8rDCzlQX>kua<}+(TIhYY`{rWOpEBzlG*)bxMwsMdU^Q+!$F^B}Q#OTi zBXapHACTf^*E_tB^(ozq2O@pEes?5m7v?b-4fr_se6^MJHkPJ%XGYunM;` zWaiC8?AV54EqB^}7VV4HGwQWau2|ekGd8O7V30TYeX2Wi9XX|q=f;>vELI>Ck3kZL z>jX5IvFI0f7)K?ibSEM#{4IdHhpn9rImvylz9bNXwNj{kNVer94?nd#E#TZls`C4I iXvGI@&?KwetMD7XpC{*sXhY@z0000CQ|4P=nZ(RNUD+(G&g{pAbG9Y1QQhXvB?PvCunklP$fZqYicb?ON1DG=;Fgkw@bI~&sOE$fF(lsF(Sl0dlr_2 zvG714YKpAJ@R_gxh(*55bVa+w*ArL*#+-I%c)|F6*2UBZc0a?}#Mk9;dZy%URqjp* zg(F>v+wMQMdGv70mn?+Cygq#O#{K2H>iv#IPB=CX*K6+IXU24oCrw9^vsG=gJ=tvpsru!?^J(^eml({Z zd3msQ=4Inc@^l;{(v7~4Q~vTtcO~fO2LV)UBf zk3?Ku2aMBm>)#sDUH4oFZS@wZOS~D(xqByVo^>ZWZ zc+;Q783cA(Gb(>QeAtIMAW}mmN=m5sX%p&^g*i<9$Q*!;6n* z=gsokj-Pg+&WMljG9zIa7Fk{;Ln#T3J|vO`_#{+#NI@468Pwt>d$;*%y3CCdc%(=7 z2YThY{+!;0|4bmK^-_yZi;C$taiS(rcnAqP)3f?sC*Pt<3xS;G4gWyi%&YR60Sh8v zOrxV12(5l>$AUmCfspWV%?#6Lz+#Pi zVWCzcB}3P&!}b}dSYs*N2IpvHBElI-+MZ{p7e}8fqU0Z;te76}K;&=$0000 + + + + + + + diff --git a/StockMate/StockMate/resources/Color.swift b/StockMate/StockMate/resources/Color.swift index 44ecf7b..12b1d5e 100644 --- a/StockMate/StockMate/resources/Color.swift +++ b/StockMate/StockMate/resources/Color.swift @@ -33,6 +33,9 @@ extension Color { static let Dark = Color(hex: "#04150C") static let Light = Color(hex: "#F7F7F7") + static let GrayMordern400 = Color(hex: "#9AA4B2") + + // Text static let textBlack = Color(hex: "#152C07") From 43d3d9329902ee940e0a84e2b8199b72a20014fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Wed, 5 Nov 2025 10:01:52 +0900 Subject: [PATCH 15/22] =?UTF-8?q?[FEAT]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/user/ui/ProfileCircleView.swift | 16 +- .../app/feature/user/ui/ProfileView.swift | 6 +- .../app/feature/user/ui/UserProfileView.swift | 146 ++++++++++++++++++ StockMate/StockMate/resources/Color.swift | 1 + 4 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 StockMate/StockMate/app/feature/user/ui/UserProfileView.swift diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift index 16fbe17..2e1a97d 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift @@ -18,12 +18,16 @@ struct ProfileCircleView: View { } var body: some View { - Text(initials) - .font(.headline) - .foregroundColor(Color(hex: "#374EAF")) - .frame(width: size, height: size) - .background(Color(hex: "#DCE0F1")) // 고정 색상 - .clipShape(Circle()) + GeometryReader { geometry in + let minSide = min(geometry.size.width, geometry.size.height) + Text(initials) + .font(.system(size: minSide * 0.35, weight: .regular)) // ✅ 내부 크기 비례 + .foregroundColor(Color(hex: "#374EAF")) + .frame(width: geometry.size.width, height: geometry.size.height) + .background(Color(hex: "#DCE0F1")) + .clipShape(Circle()) + } + .frame(width: size, height: size) } } diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift index bf330f7..016e820 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -11,7 +11,6 @@ struct ProfileView: View { @StateObject private var userViewModel = UserViewModel() var body: some View { -// NavigationStack { VStack(alignment: .leading, spacing: 24) { // MARK: - Profile Header @@ -39,11 +38,11 @@ struct ProfileView: View { // MARK: - General Section VStack(alignment: .leading, spacing: 12) { VStack(spacing: 10) { - SettingRow(icon: "user", title: "프로필 수정") + SettingNavigationRow(icon: "user", title: "프로필 확인", destination: UserProfileView()) + SettingRow(icon: "lock", title: "비밀번호 변경") SettingRow(icon: "notification", title: "알림") SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: DepositHistoryView()) -// SettingRow(icon: "credit", title: "예치금 히스토리") SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView()) SettingRow(icon: "logout", title: "로그아웃") } @@ -60,7 +59,6 @@ struct ProfileView: View { .onAppear { Task { await userViewModel.loadUserInfo() } } -// } } } diff --git a/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift new file mode 100644 index 0000000..fb70701 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift @@ -0,0 +1,146 @@ +// +// UserProfileView.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + +import SwiftUI + +struct UserProfileView: View { + @StateObject private var userViewModel = UserViewModel() + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + Spacer() + ProfileCircleView(name: userViewModel.userInfo?.owner ?? "사용자", size: 103) + Spacer() + } + .padding(.vertical, 32) + + VStack(spacing: 9) { + Text("대표자") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(userViewModel.userInfo?.owner ?? "이름 없음") + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + + Text("이에일") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(userViewModel.userInfo?.email ?? "이메일 없음") + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + + Text("지점") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(userViewModel.userInfo?.storeName ?? "지점명 없음") + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + + Text("주소") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(userViewModel.userInfo?.address ?? "주소 없음") + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + + Text("사업자등록번호") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(userViewModel.userInfo?.businessNumber ?? "사업자등록번호 없음") + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + + Spacer() + + } + .padding(3) + .cornerRadius(12) + .padding(.horizontal) + } + .navigationTitle("프로필 확인") + .background(Color.Light) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + Task { await userViewModel.loadUserInfo() } + } + } +} + +#Preview { + UserProfileView() +} diff --git a/StockMate/StockMate/resources/Color.swift b/StockMate/StockMate/resources/Color.swift index 12b1d5e..a5baa30 100644 --- a/StockMate/StockMate/resources/Color.swift +++ b/StockMate/StockMate/resources/Color.swift @@ -33,6 +33,7 @@ extension Color { static let Dark = Color(hex: "#04150C") static let Light = Color(hex: "#F7F7F7") + static let GrayMordern300 = Color(hex: "#CDD5DF") static let GrayMordern400 = Color(hex: "#9AA4B2") From 58b8fc0d34ec8c6950db57bf8ae4d5437e5a8107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Wed, 5 Nov 2025 17:26:49 +0900 Subject: [PATCH 16/22] =?UTF-8?q?[FEAT]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=9A=B0?= =?UTF-8?q?=ED=8E=B8=EB=B2=88=ED=98=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- StockMate/StockMate/ContentView.swift | 71 ++++----------- .../core/KakaoPostcode/KakaoZipCodeVC.swift | 80 +++++++++++++++++ .../core/KakaoPostcode/KakaoZipCodeView.swift | 24 ++++++ .../core/KakaoPostcode/ViewController.swift | 53 ++++++++++++ .../app/core/components/CustomTextField.swift | 17 +++- .../app/feature/auth/ui/RegisterView.swift | 86 +++++++++++++++++-- .../app/feature/user/ui/ProfileView.swift | 38 +++++++- .../StockMate/app/navigation/AppNavHost.swift | 1 + 8 files changed, 306 insertions(+), 64 deletions(-) create mode 100644 StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift create mode 100644 StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeView.swift create mode 100644 StockMate/StockMate/app/core/KakaoPostcode/ViewController.swift diff --git a/StockMate/StockMate/ContentView.swift b/StockMate/StockMate/ContentView.swift index 4d36f82..5787aab 100644 --- a/StockMate/StockMate/ContentView.swift +++ b/StockMate/StockMate/ContentView.swift @@ -7,67 +7,30 @@ import SwiftUI -//struct ContentView: View { -// @State private var showingScanner = false -// @State private var scannedCode: String? = nil -// -// var body: some View { -// NavigationView { -// VStack(spacing: 20) { -// if let code = scannedCode { -// Text("스캔 결과:") -// .font(.headline) -// Text(code) -// .font(.body) -// .multilineTextAlignment(.center) -// .padding() -// .background(Color(.systemGray6)) -// .cornerRadius(8) -// } else { -// Text("아직 스캔된 코드가 없습니다.") -// .foregroundColor(.secondary) -// } -// -// Button("QR 스캔 시작") { -// // 카메라 권한 체크는 시스템이 자동으로 권한 알림을 띄우므로 -// // 필요하면 권한 상태 확인 로직 추가 가능 -// showingScanner = true -// } -// .buttonStyle(.borderedProminent) -// .padding(.top) -// -// Spacer() -// } -// .padding() -// .navigationTitle("QR 스캐너 예제") -// .sheet(isPresented: $showingScanner) { -// QRScannerView(isPresented: $showingScanner, scannedCode: $scannedCode) -// .edgesIgnoringSafeArea(.all) -// } -// } -// } -//} struct ContentView: View { + @State private var address: String = "주소를 선택하세요" + @State private var showWebView = false + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("임시 화면") - } - .padding() - HStack(spacing: 20) { - Image(systemName: "gearshape") - .font(.system(size: 40)) - .foregroundColor(.blue) + VStack(spacing: 20) { + Text(address) + .font(.title3) + .multilineTextAlignment(.center) + .padding() - Image(systemName: "lightbulb") - .font(.system(size: 40)) - .foregroundColor(.cyan) + Button("주소 검색") { + showWebView.toggle() + } + .font(.headline) + .buttonStyle(.borderedProminent) + } + .sheet(isPresented: $showWebView) { + KakaoZipCodeView(address: $address) } } } + #Preview { ContentView() } diff --git a/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift new file mode 100644 index 0000000..a4fecb1 --- /dev/null +++ b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift @@ -0,0 +1,80 @@ +// +// KakaoZipCodeVC.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + + +import UIKit +import WebKit + +class KakaoZipCodeVC: UIViewController { + + // MARK: - Properties + var webView: WKWebView? + let indicator = UIActivityIndicatorView(style: .medium) + var onAddressSelected: ((String) -> Void)? // ✅ SwiftUI로 결과 전달용 콜백 + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + setupWebView() + setupLayout() + } + + private func setupWebView() { + let contentController = WKUserContentController() + contentController.add(self, name: "callBackHandler") + + let config = WKWebViewConfiguration() + config.userContentController = contentController + + webView = WKWebView(frame: .zero, configuration: config) + webView?.navigationDelegate = self + + guard let webView = webView, + let url = URL(string: "https://yoo-hyuna.github.io/Kakao-Postcode/") else { return } + + webView.load(URLRequest(url: url)) + } + + private func setupLayout() { + guard let webView = webView else { return } + view.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + webView.addSubview(indicator) + indicator.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + indicator.centerXAnchor.constraint(equalTo: webView.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: webView.centerYAnchor) + ]) + } +} + +extension KakaoZipCodeVC: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard let data = message.body as? [String: Any] else { return } + let address = data["roadAddress"] as? String ?? "" + onAddressSelected?(address) // ✅ SwiftUI로 전달 + dismiss(animated: true) + } +} + +extension KakaoZipCodeVC: WKNavigationDelegate { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + indicator.startAnimating() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + indicator.stopAnimating() + } +} diff --git a/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeView.swift b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeView.swift new file mode 100644 index 0000000..fa69a87 --- /dev/null +++ b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeView.swift @@ -0,0 +1,24 @@ +// +// KakaoZipCodeView.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + + +import SwiftUI +import WebKit + +struct KakaoZipCodeView: UIViewControllerRepresentable { + @Binding var address: String + + func makeUIViewController(context: Context) -> KakaoZipCodeVC { + let vc = KakaoZipCodeVC() + vc.onAddressSelected = { selectedAddress in + address = selectedAddress + } + return vc + } + + func updateUIViewController(_ uiViewController: KakaoZipCodeVC, context: Context) {} +} diff --git a/StockMate/StockMate/app/core/KakaoPostcode/ViewController.swift b/StockMate/StockMate/app/core/KakaoPostcode/ViewController.swift new file mode 100644 index 0000000..2c793d0 --- /dev/null +++ b/StockMate/StockMate/app/core/KakaoPostcode/ViewController.swift @@ -0,0 +1,53 @@ +// +// ViewController.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + + +import UIKit + +class ViewController: UIViewController { + + // MARK: - UI Components + let button = UIButton(type: .system) + let label = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + configureUI() + } + + private func configureUI() { + [label, button].forEach { + view.addSubview($0) + $0.translatesAutoresizingMaskIntoConstraints = false + } + + label.text = "주소를 선택하세요" + label.font = UIFont.systemFont(ofSize: 18) + label.textAlignment = .center + + button.setTitle("주소 검색", for: .normal) + button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 18) + button.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -50), + label.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8), + + button.centerXAnchor.constraint(equalTo: view.centerXAnchor), + button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 30) + ]) + } + + @objc + private func handleButton(_ sender: UIButton) { + let vc = KakaoZipCodeVC() + vc.modalPresentationStyle = .fullScreen + present(vc, animated: true) + } +} diff --git a/StockMate/StockMate/app/core/components/CustomTextField.swift b/StockMate/StockMate/app/core/components/CustomTextField.swift index 62eedc3..074d2c2 100644 --- a/StockMate/StockMate/app/core/components/CustomTextField.swift +++ b/StockMate/StockMate/app/core/components/CustomTextField.swift @@ -13,6 +13,7 @@ struct CustomTextField: View { @Binding var text: String var isEmail: Bool = false var errorMessage: String? = nil + var isReadOnly: Bool = false // ✅ 추가 @FocusState private var isFocused: Bool @@ -44,13 +45,27 @@ struct CustomTextField: View { y: 2 ) + if isReadOnly { + // ✅ 가로 스크롤 가능한 읽기 전용 텍스트 + ScrollView(.horizontal, showsIndicators: false) { + Text(text.isEmpty ? placeholder : text) + .font(.system(size: 15)) + .foregroundColor(text.isEmpty ? .gray : .black) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .lineLimit(1) + } + .frame(height: 46) + .contentShape(Rectangle()) + + } else { TextField(placeholder, text: $text) .focused($isFocused) .padding(.horizontal, 14) .padding(.vertical, 12) .textInputAutocapitalization(.never) .autocorrectionDisabled() - + } } .frame(height: 46) // 높이 일정하게 고정 diff --git a/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift b/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift index 7656f2c..2e29490 100644 --- a/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift @@ -30,6 +30,9 @@ struct RegisterView: View { @State private var isLoading = false @State private var showToast = false + @State private var showAddressSearch = false + + var body: some View { ScrollView { VStack(spacing: 16) { @@ -83,19 +86,48 @@ struct RegisterView: View { text: $storeName, errorMessage: storeNameError ) - CustomTextField( - title: "주소", - placeholder: "서울특별시 강남구 ...", - text: $address, - errorMessage: addressError - ) + // ✅ 주소 입력 필드 + 버튼 추가 부분 + VStack(alignment: .leading, spacing: 4) { + HStack { + CustomTextField( + title: "주소", + placeholder: "서울특별시 강남구 ...", + text: $address, + errorMessage: addressError, + isReadOnly: true // ✅ 추가 + ) + .disabled(true) // 사용자가 직접 입력 못하게 + .onTapGesture { + // 탭해도 검색창 열 수 있게 (선택사항) + showAddressSearch.toggle() + } + + Button(action: { + showAddressSearch.toggle() + }) { + Text("주소 검색") + .font(.system(size: 14, weight: .semibold)) + .frame(height: 43) + .padding(.horizontal, 12) + .background(Color.Primary) + .foregroundColor(.white) + .cornerRadius(8) + } + .sheet(isPresented: $showAddressSearch) { + KakaoZipCodeView(address: $address) + } + } + } CustomTextField( title: "사업자등록번호", placeholder: "123-45-67890", text: $bizNo, errorMessage: bizNoError ) - .keyboardType(.numbersAndPunctuation) + .keyboardType(.numberPad) + .onChange(of: bizNo) { newValue in + formatBizNoInput(newValue) + } } .padding(.horizontal, 24) @@ -138,10 +170,15 @@ struct RegisterView: View { } } .padding(.bottom, 40) + + // ✅ 키보드 가림 방지용 여백 + Spacer().frame(height: 300) } } .background(Color.Light) .ignoresSafeArea() + .scrollDismissesKeyboard(.interactively) // ✅ 손가락으로 스크롤하면 키보드 자동 내려감 + } // MARK: - 유효성 검사 함수 @@ -212,9 +249,42 @@ struct RegisterView: View { owner: owner, address: address, storeName: storeName, - bizNo: bizNo + bizNo: bizNo.filter { $0.isNumber } // ← 여기서 숫자만 추출해서 전송 ) isLoading = false } } + + private func formatBizNoInput(_ input: String) { + // 1️⃣ 숫자만 남기기 + let digitsOnly = input.filter { $0.isNumber } + + // 2️⃣ 하이픈 자동 삽입 + var formatted = "" + let length = digitsOnly.count + + if length <= 3 { + formatted = digitsOnly + } else if length <= 5 { + formatted = "\(digitsOnly.prefix(3))-\(digitsOnly.suffix(from: digitsOnly.index(digitsOnly.startIndex, offsetBy: 3)))" + } else { + let first = digitsOnly.prefix(3) + let middleStart = digitsOnly.index(digitsOnly.startIndex, offsetBy: 3) + let middleEnd = digitsOnly.index(middleStart, offsetBy: 2, limitedBy: digitsOnly.endIndex) ?? digitsOnly.endIndex + let middle = digitsOnly[middleStart.. 10 { + formatted = String(formatted.prefix(12)) // 하이픈 포함 + } + + // 4️⃣ 상태 업데이트 + if formatted != bizNo { + bizNo = formatted + } + } } + diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift index 016e820..55d236c 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -9,8 +9,11 @@ import SwiftUI struct ProfileView: View { @StateObject private var userViewModel = UserViewModel() + @EnvironmentObject var authViewModel: AuthViewModel // 🔹 전역 Auth 상태 참조 + @State private var showLogoutModal = false // 🔹 로그아웃 모달 상태 var body: some View { + ZStack{ VStack(alignment: .leading, spacing: 24) { // MARK: - Profile Header @@ -44,7 +47,13 @@ struct ProfileView: View { SettingRow(icon: "notification", title: "알림") SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: DepositHistoryView()) SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView()) - SettingRow(icon: "logout", title: "로그아웃") + // SettingRow(icon: "logout", title: "로그아웃") + // 🔹 로그아웃 버튼 + Button { + showLogoutModal = true + } label: { + SettingRow(icon: "logout", title: "로그아웃") + } } .padding(3) .background(Color.Light) @@ -59,6 +68,33 @@ struct ProfileView: View { .onAppear { Task { await userViewModel.loadUserInfo() } } + + // 🔹 AlertModal (ZStack 위에 오버레이로 표시) + if showLogoutModal { + Color.black.opacity(0.3) + .ignoresSafeArea() + .transition(.opacity) + + AlertModal( + title: "로그아웃", + message: "정말 로그아웃 하시겠습니까?", + primaryButtonTitle: "로그아웃", + primaryAction: { + authViewModel.logout() + showLogoutModal = false + }, + secondaryButtonTitle: "취소", + secondaryAction: { + showLogoutModal = false + }, + buttonLayout: .horizontal + ) + .transition(.scale) + .zIndex(1) + } + + } + .animation(.easeInOut, value: showLogoutModal) } } diff --git a/StockMate/StockMate/app/navigation/AppNavHost.swift b/StockMate/StockMate/app/navigation/AppNavHost.swift index c55ea0a..ffb9779 100644 --- a/StockMate/StockMate/app/navigation/AppNavHost.swift +++ b/StockMate/StockMate/app/navigation/AppNavHost.swift @@ -24,6 +24,7 @@ struct AppNavHost: View { RegisterView() case .authenticated: MainTabView() + .environmentObject(authViewModel) .environmentObject(partStore) } } From c8419f93c06fa364f9351869ff386b4ad537ccc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Thu, 6 Nov 2025 10:59:08 +0900 Subject: [PATCH 17/22] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=B9=98=EA=B8=88=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/feature/auth/ui/HomeView.swift | 2 +- .../feature/dashboard/data/HistoryApi.swift | 8 ++ .../dashboard/ui/DepositHistoryView.swift | 135 ------------------ .../ui/TransactionTypeListView.swift | 108 ++++++++++++++ .../viewmodel/HistoryViewModel.swift | 31 ++-- .../app/feature/orders/ui/ReceiptView.swift | 20 ++- .../app/feature/user/ui/ProfileView.swift | 3 +- 7 files changed, 160 insertions(+), 147 deletions(-) delete mode 100644 StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift create mode 100644 StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift index 615d4f4..fa9e0b8 100644 --- a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -224,7 +224,7 @@ private func iconForCategory(_ name: String) -> String { case "하체/바디": return "spanner" case "내장/외장": return "chair" case "기타소모품": return "package" - default: return "questionmark" + default: return "uploadprogress" } } diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift index 9c63204..9da72bc 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -84,9 +84,17 @@ struct PaymentTransactionItem: Decodable, Identifiable { let transactionTime: String? // ✅ null 허용 let totalAmount: Int let orderId: Int? // ✅ null 허용 + let orderItems: [OrderItemHistory]? let balance: Int } +struct OrderItemHistory: Decodable { + let id: Int + let name: String + let image: String + let korName: String + let categoryName: String +} // MARK: - API diff --git a/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift b/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift deleted file mode 100644 index 97f8c6c..0000000 --- a/StockMate/StockMate/app/feature/dashboard/ui/DepositHistoryView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// DepositHistoryView.swift -// StockMate -// -// Created by Admin on 11/3/25. -// - -import SwiftUI - -struct DepositHistoryView: View { - @StateObject private var viewModel = HistoryViewModel() - - var body: some View { - VStack { - // 본문 스크롤 - ScrollView { - LazyVStack(spacing: 12) { - ForEach(viewModel.transactions) { item in - DepositHistoryRow(item: item) - .onAppear { - Task { - await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) - } - } - } - - if viewModel.isTransactionLoading { - ProgressView() - .padding() - } - } - .padding(.horizontal) - } - .background(Color.Light) - .navigationTitle("예치금 히스토리") - .navigationBarTitleDisplayMode(.inline) - .refreshable { - await viewModel.fetchPaymentTransactions() - } - } - .task { - await viewModel.fetchPaymentTransactions() - } - } -} - -// MARK: - 개별 거래 Row -struct DepositHistoryRow: View { - let item: PaymentTransactionItem - - var isCharge: Bool { item.transactionType == "CHARGE" } - - var body: some View { - HStack(alignment: .center, spacing: 13) { - // 아이콘 - Image(isCharge ? "exchange" : "bag") - .frame(width: 64, height: 64) - .foregroundColor(isCharge ? .Primary : .gray) - - VStack(alignment: .leading, spacing: 4) { - Text(isCharge ? "예치금 충전" : "실린더 어셈블리-브레이크 마스터 외 3개") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.black) - - Text(formatDate(item.transactionTime ?? "")) - .font(.caption) - .foregroundColor(.gray) - - Text(formatAmount(item.totalAmount, isCharge: isCharge)) - .font(.subheadline) - .foregroundColor(isCharge ? .Primary : .red) - } - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.gray) - } - .padding() - .background(Color.white) - .cornerRadius(12) - .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) - } - - // MARK: - Helper - private func formatAmount(_ amount: Int, isCharge: Bool) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" - return (isCharge ? "+ " : "- ") + formatted + "원" - } - - private func formatDate(_ dateString: String) -> String { - // 서버에서 "2025.10.29 17:32:39" 형식이면 그대로 반환 - // 혹은 ISO8601이면 변환 필요 - if dateString.contains(".") { - return dateString - } else { - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: dateString) { - let displayFormatter = DateFormatter() - displayFormatter.dateFormat = "yyyy.MM.dd HH:mm:ss" - return displayFormatter.string(from: date) - } - return dateString - } - } -} - -// MARK: - Dummy Preview -#Preview { - VStack(spacing: 16) { - DepositHistoryRow( - item: PaymentTransactionItem( - transactionType: "CHARGE", - transactionTime: "2025-11-03T09:12:45", - totalAmount: 50000, - orderId: nil, - balance: 50000 - ) - ) - DepositHistoryRow( - item: PaymentTransactionItem( - transactionType: "PAY", - transactionTime: "2025-11-03T14:34:35.608713", - totalAmount: 49720, - orderId: 61, - balance: 4954617 - ) - ) - } - .padding() - .background(Color.Light) -} diff --git a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift new file mode 100644 index 0000000..4407d1a --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift @@ -0,0 +1,108 @@ +// +// TransactionTypeListView.swift +// StockMate +// +// Created by Admin on 11/6/25. +// + +import SwiftUI + +struct TransactionTypeListView: View { + @StateObject private var viewModel = HistoryViewModel() + + var body: some View { + NavigationView { + List(viewModel.transactions, id: \.id) { item in + HStack { + // 대표 이미지 + AsyncImage(url: URL(string: item.orderItems?.first?.image ?? "")) { image in + image.resizable().scaledToFit() + } placeholder: { + Image("exchange") + .foregroundColor(Color.Primary) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + + VStack (alignment: .leading, spacing: 3){ + + // ✅ PAY/CHARGE 별 상세 표시 (부품 이름/ 예치금 충전) + if item.transactionType == "PAY", + let orderItems = item.orderItems, + !orderItems.isEmpty { + let firstName = orderItems.first?.korName ?? "-" + let extraCount = orderItems.count - 1 + let displayText = extraCount > 0 + ? "\(firstName) 외 \(extraCount)개" + : firstName + + Text(displayText) + .font(.subheadline) + .foregroundColor(.primary) + } else if item.transactionType == "CHARGE" { + Text("예치금 충전") + .font(.subheadline) + .foregroundColor(.blue) + } + + // ✅ 거래 시간 (포맷 적용) + if let time = item.transactionTime { + Text(formattedDate(time)) + .font(.subheadline) + .foregroundColor(.gray) + } else { + Text("-") + .font(.subheadline) + .foregroundColor(.gray) + } + + + // ✅ 거래 금액 표시 (CHARGE: +파란색 / PAY: -빨간색) + let sign = item.transactionType == "PAY" ? "-" : "+" + let color: Color = item.transactionType == "PAY" ? .Danger : .Primary + + Text("\(sign) \(formatPrice(item.totalAmount))") + .font(.headline) + .foregroundColor(color) + .frame(alignment: .trailing) + + } + Spacer() + + if item.transactionType == "PAY" { + NavigationLink(destination: ReceiptView(orderId: item.orderId ?? 1)) { + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.gray) + } + } + + } + .padding(.vertical, 4) + .task { + // ✅ 스크롤 시 끝 근처에서 다음 페이지 로드 + await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) + } + + } + .background(Color.Light) + .navigationTitle("예치금 히스토리") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isTransactionLoading && viewModel.transactions.isEmpty { + ProgressView("불러오는 중...") + } + } + .task { + if viewModel.transactions.isEmpty { + await viewModel.fetchPaymentTransactions() + } + } + } + } +} + +#Preview { + TransactionTypeListView() +} diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift index e01b262..b1848de 100644 --- a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -95,13 +95,26 @@ final class HistoryViewModel: ObservableObject { } // MARK: - ✅ 무한 스크롤 (예치금 내역) - func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { - guard let item = item else { return } - let thresholdIndex = transactions.index(transactions.endIndex, offsetBy: -5) - if transactions.firstIndex(where: { $0.id == item.id }) == thresholdIndex { - if transactionPage + 1 < transactionTotalPages { - await fetchPaymentTransactions(page: transactionPage + 1) - } - } - } + func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { + guard let item = item else { return } + guard !isTransactionLoading else { return } // 이미 로딩 중이면 중복 호출 방지 + guard transactionPage + 1 < transactionTotalPages else { return } // 더 불러올 페이지 없으면 종료 + + // 안전하게 threshold 계산 + let thresholdIndex = max(transactions.count - 5, 0) + if let currentIndex = transactions.firstIndex(where: { $0.id == item.id }), + currentIndex >= thresholdIndex { + await fetchPaymentTransactions(page: transactionPage + 1) + } + } + +// func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { +// guard let item = item else { return } +// let thresholdIndex = transactions.index(transactions.endIndex, offsetBy: -5) +// if transactions.firstIndex(where: { $0.id == item.id }) == thresholdIndex { +// if transactionPage + 1 < transactionTotalPages { +// await fetchPaymentTransactions(page: transactionPage + 1) +// } +// } +// } } diff --git a/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift b/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift index 9e690f1..91b100a 100644 --- a/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift @@ -209,10 +209,27 @@ struct ReceiptView: View { // } //} +//func formattedDate(_ timestamp: String) -> String { +// let inputFormatter = DateFormatter() +// inputFormatter.locale = Locale(identifier: "ko_KR") +// inputFormatter.timeZone = TimeZone(abbreviation: "UTC") +// inputFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" +// +// guard let date = inputFormatter.date(from: timestamp) else { +// return timestamp +// } +// +// let outputFormatter = DateFormatter() +// outputFormatter.locale = Locale(identifier: "ko_KR") +// outputFormatter.timeZone = TimeZone.current +// outputFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss" +// +// return outputFormatter.string(from: date) +//} func formattedDate(_ timestamp: String) -> String { let inputFormatter = DateFormatter() inputFormatter.locale = Locale(identifier: "ko_KR") - inputFormatter.timeZone = TimeZone(abbreviation: "UTC") + inputFormatter.timeZone = TimeZone.current // ✅ 실제 한국 시간 기준 inputFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" guard let date = inputFormatter.date(from: timestamp) else { @@ -227,6 +244,7 @@ func formattedDate(_ timestamp: String) -> String { return outputFormatter.string(from: date) } + func formattedApprovalNumber(_ timestamp: String) -> String { let inputFormatter = DateFormatter() inputFormatter.locale = Locale(identifier: "ko_KR") diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift index 55d236c..6892def 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -45,7 +45,8 @@ struct ProfileView: View { SettingRow(icon: "lock", title: "비밀번호 변경") SettingRow(icon: "notification", title: "알림") - SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: DepositHistoryView()) + SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: TransactionTypeListView()) +// SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: PaymentTransactionView()) SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView()) // SettingRow(icon: "logout", title: "로그아웃") // 🔹 로그아웃 버튼 From 5bcbaf8a5ea8ae5013fd6666d72c27eb82edce0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Thu, 6 Nov 2025 15:50:49 +0900 Subject: [PATCH 18/22] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=B9=98=EA=B8=88=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/dashboard/data/HistoryApi.swift | 9 +- .../ui/TransactionTypeListView.swift | 285 +++++++++++++----- .../viewmodel/HistoryViewModel.swift | 19 +- 3 files changed, 220 insertions(+), 93 deletions(-) diff --git a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift index 9da72bc..97f41d9 100644 --- a/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -78,16 +78,17 @@ struct PaymentTransactionPageData: Decodable { let first: Bool } -struct PaymentTransactionItem: Decodable, Identifiable { - var id: UUID { UUID() } // 서버에서 id 제공 안하므로 로컬 생성 +struct PaymentTransactionItem: Decodable { + let transactionId: Int let transactionType: String // "CHARGE" or "PAY" - let transactionTime: String? // ✅ null 허용 + let transactionTime: String? let totalAmount: Int - let orderId: Int? // ✅ null 허용 + let orderId: Int? let orderItems: [OrderItemHistory]? let balance: Int } + struct OrderItemHistory: Decodable { let id: Int let name: String diff --git a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift index 4407d1a..d004a72 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift @@ -1,3 +1,118 @@ +//// +//// TransactionTypeListView.swift +//// StockMate +//// +//// Created by Admin on 11/6/25. +//// +// +//import SwiftUI +// +//struct TransactionTypeListView: View { +// @StateObject private var viewModel = HistoryViewModel() +// +// var body: some View { +// NavigationView { +// List(viewModel.transactions, id: \.id) { item in +// HStack { +// // 대표 이미지 +// AsyncImage(url: URL(string: item.orderItems?.first?.image ?? "")) { image in +// image.resizable().scaledToFit() +// } placeholder: { +// Image("exchange") +// .foregroundColor(Color.Primary) +// } +// .frame(width: 64, height: 64) +// .cornerRadius(10) +// +// +// VStack (alignment: .leading, spacing: 3){ +// +// // ✅ PAY/CHARGE 별 상세 표시 (부품 이름/ 예치금 충전) +// if item.transactionType == "PAY", +// let orderItems = item.orderItems, +// !orderItems.isEmpty { +// NavigationLink(destination: ReceiptView(orderId: item.orderId ?? 1)) { +// let firstName = orderItems.first?.korName ?? "-" +// let extraCount = orderItems.count - 1 +// let displayText = extraCount > 0 +// ? "\(firstName) 외 \(extraCount)개" +// : firstName +// +// VStack(alignment: .leading){ +// Text(displayText) +// .font(.subheadline) +// .foregroundColor(.primary) +// +// // ✅ 거래 시간 (포맷 적용) +// if let time = item.transactionTime { +// Text(formattedDate(time)) +// .font(.subheadline) +// .foregroundColor(.gray) +// } else { +// Text("-") +// .font(.subheadline) +// .foregroundColor(.gray) +// } +// +// Text("- \(formatPrice(item.totalAmount))") +// .font(.headline) +// .foregroundColor(.Danger) +// .frame(alignment: .trailing) +// } +// } +// +// } else if item.transactionType == "CHARGE" { +// Text("예치금 충전") +// .font(.subheadline) +// .foregroundColor(.blue) +// +// // ✅ 거래 시간 (포맷 적용) +// if let time = item.transactionTime { +// Text(formattedDate(time)) +// .font(.subheadline) +// .foregroundColor(.gray) +// } else { +// Text("-") +// .font(.subheadline) +// .foregroundColor(.gray) +// } +// +// Text("+ \(formatPrice(item.totalAmount))") +// .font(.headline) +// .foregroundColor(.Primary) +// .frame(alignment: .trailing) +// } +// +// } +// +// } +// .padding(.vertical, 4) +// .task { +// // ✅ 스크롤 시 끝 근처에서 다음 페이지 로드 +// await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) +// } +// } +// .background(Color.Light) +// .navigationTitle("예치금 히스토리") +// .navigationBarTitleDisplayMode(.inline) +// .overlay { +// if viewModel.isTransactionLoading && viewModel.transactions.isEmpty { +// ProgressView("불러오는 중...") +// } +// } +// .task { +// if viewModel.transactions.isEmpty { +// await viewModel.fetchPaymentTransactions() +// } +// } +// } +// .background(Color.Light) +// } +//} +// +////#Preview { +//// TransactionTypeListView() +////} // // TransactionTypeListView.swift // StockMate @@ -11,82 +126,22 @@ struct TransactionTypeListView: View { @StateObject private var viewModel = HistoryViewModel() var body: some View { - NavigationView { - List(viewModel.transactions, id: \.id) { item in - HStack { - // 대표 이미지 - AsyncImage(url: URL(string: item.orderItems?.first?.image ?? "")) { image in - image.resizable().scaledToFit() - } placeholder: { - Image("exchange") - .foregroundColor(Color.Primary) - } - .frame(width: 64, height: 64) - .cornerRadius(10) - - - VStack (alignment: .leading, spacing: 3){ - - // ✅ PAY/CHARGE 별 상세 표시 (부품 이름/ 예치금 충전) - if item.transactionType == "PAY", - let orderItems = item.orderItems, - !orderItems.isEmpty { - let firstName = orderItems.first?.korName ?? "-" - let extraCount = orderItems.count - 1 - let displayText = extraCount > 0 - ? "\(firstName) 외 \(extraCount)개" - : firstName - - Text(displayText) - .font(.subheadline) - .foregroundColor(.primary) - } else if item.transactionType == "CHARGE" { - Text("예치금 충전") - .font(.subheadline) - .foregroundColor(.blue) - } - - // ✅ 거래 시간 (포맷 적용) - if let time = item.transactionTime { - Text(formattedDate(time)) - .font(.subheadline) - .foregroundColor(.gray) - } else { - Text("-") - .font(.subheadline) - .foregroundColor(.gray) - } - - - // ✅ 거래 금액 표시 (CHARGE: +파란색 / PAY: -빨간색) - let sign = item.transactionType == "PAY" ? "-" : "+" - let color: Color = item.transactionType == "PAY" ? .Danger : .Primary - - Text("\(sign) \(formatPrice(item.totalAmount))") - .font(.headline) - .foregroundColor(color) - .frame(alignment: .trailing) - - } - Spacer() - - if item.transactionType == "PAY" { - NavigationLink(destination: ReceiptView(orderId: item.orderId ?? 1)) { - Image(systemName: "chevron.right") - .font(.caption) - .foregroundColor(.gray) - } + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.transactions, id: \.transactionId) { item in + TransactionCard(item: item) + .onAppear { + Task { + await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) + } + } } - } - .padding(.vertical, 4) - .task { - // ✅ 스크롤 시 끝 근처에서 다음 페이지 로드 - await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) - } - + .padding(.horizontal) + .padding(.top, 10) } - .background(Color.Light) + .background(Color.Light.ignoresSafeArea()) .navigationTitle("예치금 히스토리") .navigationBarTitleDisplayMode(.inline) .overlay { @@ -100,9 +155,89 @@ struct TransactionTypeListView: View { } } } + .background(Color.Light) } } -#Preview { - TransactionTypeListView() +struct TransactionCard: View { + let item: PaymentTransactionItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + // 대표 이미지 + AsyncImage( + url: URL(string: item.orderItems?.first?.image ?? "") + ) { image in + image.resizable().scaledToFit() + } placeholder: { + Image("exchange") + .foregroundColor(Color.Primary) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + if item.transactionType == "PAY", + let orderItems = item.orderItems, + !orderItems.isEmpty { + + NavigationLink( + destination: ReceiptView(orderId: item.orderId ?? 1) + ) { + let firstName = orderItems.first?.korName ?? "-" + let extraCount = orderItems.count - 1 + let displayText = extraCount > 0 + ? "\(firstName) 외 \(extraCount)개" + : firstName + + VStack(alignment: .leading, spacing: 4) { + Text(displayText) + .font(.subheadline) + .foregroundColor(.primary) + + Text( + item.transactionTime != nil ? formattedDate( + item.transactionTime! + ) : "-" + ) + .font(.caption) + .foregroundColor(.gray) + + Text("- \(formatPrice(item.totalAmount))") + .font(.headline) + .foregroundColor(.Danger) + } + } + .buttonStyle(.plain) + + } else if item.transactionType == "CHARGE" { + VStack(alignment: .leading, spacing: 4) { + Text("예치금 충전") + .font(.subheadline) + .foregroundColor(.blue) + + Text( + item.transactionTime != nil ? formattedDate( + item.transactionTime! + ) : "-" + ) + .font(.caption) + .foregroundColor(.gray) + + Text("+ \(formatPrice(item.totalAmount))") + .font(.headline) + .foregroundColor(.Primary) + } + } + } + Spacer() + } + } + .padding() + .background(Color.white) + .cornerRadius(16) + .shadow(color: .gray.opacity(0.2), radius: 4, x: 0, y: 2) + } } + diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift index b1848de..5e1f69b 100644 --- a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -97,24 +97,15 @@ final class HistoryViewModel: ObservableObject { // MARK: - ✅ 무한 스크롤 (예치금 내역) func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { guard let item = item else { return } - guard !isTransactionLoading else { return } // 이미 로딩 중이면 중복 호출 방지 - guard transactionPage + 1 < transactionTotalPages else { return } // 더 불러올 페이지 없으면 종료 + guard !isTransactionLoading else { return } // 중복 로드 방지 + guard transactionPage + 1 < transactionTotalPages else { return } // 마지막 페이지 방지 - // 안전하게 threshold 계산 + // ✅ 안전한 threshold 계산 let thresholdIndex = max(transactions.count - 5, 0) - if let currentIndex = transactions.firstIndex(where: { $0.id == item.id }), + if let currentIndex = transactions.firstIndex(where: { $0.transactionId == item.transactionId }), currentIndex >= thresholdIndex { await fetchPaymentTransactions(page: transactionPage + 1) } } - -// func loadMoreTransactionsIfNeeded(currentItem item: PaymentTransactionItem?) async { -// guard let item = item else { return } -// let thresholdIndex = transactions.index(transactions.endIndex, offsetBy: -5) -// if transactions.firstIndex(where: { $0.id == item.id }) == thresholdIndex { -// if transactionPage + 1 < transactionTotalPages { -// await fetchPaymentTransactions(page: transactionPage + 1) -// } -// } -// } + } From 9db5ff85c52595a0246ffb654e33b7cfb03b4d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Thu, 6 Nov 2025 19:35:38 +0900 Subject: [PATCH 19/22] =?UTF-8?q?[FEAT]=20=EC=98=88=EC=B9=98=EA=B8=88=20?= =?UTF-8?q?=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/TransactionTypeListView.swift | 196 ++++-------------- 1 file changed, 37 insertions(+), 159 deletions(-) diff --git a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift index d004a72..79ec4dc 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift @@ -1,118 +1,3 @@ -//// -//// TransactionTypeListView.swift -//// StockMate -//// -//// Created by Admin on 11/6/25. -//// -// -//import SwiftUI -// -//struct TransactionTypeListView: View { -// @StateObject private var viewModel = HistoryViewModel() -// -// var body: some View { -// NavigationView { -// List(viewModel.transactions, id: \.id) { item in -// HStack { -// // 대표 이미지 -// AsyncImage(url: URL(string: item.orderItems?.first?.image ?? "")) { image in -// image.resizable().scaledToFit() -// } placeholder: { -// Image("exchange") -// .foregroundColor(Color.Primary) -// } -// .frame(width: 64, height: 64) -// .cornerRadius(10) -// -// -// VStack (alignment: .leading, spacing: 3){ -// -// // ✅ PAY/CHARGE 별 상세 표시 (부품 이름/ 예치금 충전) -// if item.transactionType == "PAY", -// let orderItems = item.orderItems, -// !orderItems.isEmpty { -// NavigationLink(destination: ReceiptView(orderId: item.orderId ?? 1)) { -// let firstName = orderItems.first?.korName ?? "-" -// let extraCount = orderItems.count - 1 -// let displayText = extraCount > 0 -// ? "\(firstName) 외 \(extraCount)개" -// : firstName -// -// VStack(alignment: .leading){ -// Text(displayText) -// .font(.subheadline) -// .foregroundColor(.primary) -// -// // ✅ 거래 시간 (포맷 적용) -// if let time = item.transactionTime { -// Text(formattedDate(time)) -// .font(.subheadline) -// .foregroundColor(.gray) -// } else { -// Text("-") -// .font(.subheadline) -// .foregroundColor(.gray) -// } -// -// Text("- \(formatPrice(item.totalAmount))") -// .font(.headline) -// .foregroundColor(.Danger) -// .frame(alignment: .trailing) -// } -// } -// -// } else if item.transactionType == "CHARGE" { -// Text("예치금 충전") -// .font(.subheadline) -// .foregroundColor(.blue) -// -// // ✅ 거래 시간 (포맷 적용) -// if let time = item.transactionTime { -// Text(formattedDate(time)) -// .font(.subheadline) -// .foregroundColor(.gray) -// } else { -// Text("-") -// .font(.subheadline) -// .foregroundColor(.gray) -// } -// -// Text("+ \(formatPrice(item.totalAmount))") -// .font(.headline) -// .foregroundColor(.Primary) -// .frame(alignment: .trailing) -// } -// -// } -// -// } -// .padding(.vertical, 4) -// .task { -// // ✅ 스크롤 시 끝 근처에서 다음 페이지 로드 -// await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) -// } -// } -// .background(Color.Light) -// .navigationTitle("예치금 히스토리") -// .navigationBarTitleDisplayMode(.inline) -// .overlay { -// if viewModel.isTransactionLoading && viewModel.transactions.isEmpty { -// ProgressView("불러오는 중...") -// } -// } -// .task { -// if viewModel.transactions.isEmpty { -// await viewModel.fetchPaymentTransactions() -// } -// } -// } -// .background(Color.Light) -// } -//} -// -////#Preview { -//// TransactionTypeListView() -////} // // TransactionTypeListView.swift // StockMate @@ -164,7 +49,7 @@ struct TransactionCard: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .center, spacing: 12) { // 대표 이미지 AsyncImage( url: URL(string: item.orderItems?.first?.image ?? "") @@ -177,67 +62,60 @@ struct TransactionCard: View { .frame(width: 64, height: 64) .cornerRadius(10) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 5) { if item.transactionType == "PAY", let orderItems = item.orderItems, !orderItems.isEmpty { - - NavigationLink( - destination: ReceiptView(orderId: item.orderId ?? 1) - ) { let firstName = orderItems.first?.korName ?? "-" let extraCount = orderItems.count - 1 let displayText = extraCount > 0 ? "\(firstName) 외 \(extraCount)개" : firstName - - VStack(alignment: .leading, spacing: 4) { - Text(displayText) - .font(.subheadline) - .foregroundColor(.primary) - - Text( - item.transactionTime != nil ? formattedDate( - item.transactionTime! - ) : "-" - ) - .font(.caption) - .foregroundColor(.gray) - - Text("- \(formatPrice(item.totalAmount))") - .font(.headline) - .foregroundColor(.Danger) - } - } - .buttonStyle(.plain) + Text(displayText) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) } else if item.transactionType == "CHARGE" { - VStack(alignment: .leading, spacing: 4) { Text("예치금 충전") - .font(.subheadline) - .foregroundColor(.blue) - - Text( - item.transactionTime != nil ? formattedDate( - item.transactionTime! - ) : "-" - ) - .font(.caption) - .foregroundColor(.gray) - - Text("+ \(formatPrice(item.totalAmount))") - .font(.headline) - .foregroundColor(.Primary) - } + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) } + // 날짜 + Text( + item.transactionTime != nil ? formattedDate( + item.transactionTime! + ) : "-" + ) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.textGray1) + + // ✅ 금액 표시 (PAY/CHARGE 구분) + let isPay = item.transactionType == "PAY" + let sign = isPay ? "-" : "+" + let color: Color = isPay ? .Danger : .Primary + + Text("\(sign) \(formatPrice(item.totalAmount))") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(color) + } + .frame(height: 60, alignment: .top) Spacer() + // ✅ PAY일 때만 꺾새 표시 + if item.transactionType == "PAY" { + NavigationLink(destination: ReceiptView(orderId: item.orderId ?? 1)) { + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + } } } .padding() .background(Color.white) - .cornerRadius(16) - .shadow(color: .gray.opacity(0.2), radius: 4, x: 0, y: 2) + .cornerRadius(10) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) } } From d81387162ecc9ee6adacb16b543d91b982afc102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Fri, 7 Nov 2025 10:35:40 +0900 Subject: [PATCH 20/22] =?UTF-8?q?[REFAC]=20stausText=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=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 --- StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift | 2 +- StockMate/StockMate/app/feature/user/ui/ProfileView.swift | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift index 4ab38d1..8ff3d78 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift @@ -366,7 +366,7 @@ func statusText(_ status: String) -> String { // 본사에서 "결제 완료"에 대해서 주문을 반려 or 승인 case "REJECTED": return "결제 실패" // 주문 반려 - case "APPROVAL_ORDER": return "출고 대기" // 주문 승인 + case "APPROVAL_ORDER": return "주문 승인" // 주문 승인 // 창고관리자가 "주문 승인"에 대해서 송장(인보이스)를 뽑으면 출고 대기 case "PENDING_SHIPPING": return "출고 대기" // 출고 대기 diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift index 6892def..43d8ff3 100644 --- a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -42,8 +42,6 @@ struct ProfileView: View { VStack(alignment: .leading, spacing: 12) { VStack(spacing: 10) { SettingNavigationRow(icon: "user", title: "프로필 확인", destination: UserProfileView()) - - SettingRow(icon: "lock", title: "비밀번호 변경") SettingRow(icon: "notification", title: "알림") SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: TransactionTypeListView()) // SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: PaymentTransactionView()) From 5ceb482084b82fcd33a9b554b0f361077825ea03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Fri, 7 Nov 2025 12:12:45 +0900 Subject: [PATCH 21/22] =?UTF-8?q?[REFAC]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/core/components/BarChartView.swift | 2 +- .../app/core/components/CartCard.swift | 6 ---- .../core/components/CustomButtonStyle.swift | 12 +++---- .../components/InventoryListSection.swift | 18 ---------- .../components/OrderRequestCardView.swift | 3 ++ .../app/feature/auth/ui/LoginView.swift | 8 ++--- .../viewmodel/HistoryViewModel.swift | 11 +++--- .../inventory/ui/OutgoingScanView.swift | 2 +- .../inventory/ui/UsedPartListSheetView.swift | 10 ++---- .../app/feature/orders/data/OrderApi.swift | 35 ------------------- .../app/feature/orders/ui/OrderInfoView.swift | 22 ------------ .../app/feature/parts/data/PartStore.swift | 11 ++++++ .../parts/ui/QRScannerViewController.swift | 2 +- .../parts/viewmodel/PartViewModel.swift | 4 +-- .../app/feature/user/ui/UserProfileView.swift | 2 +- 15 files changed, 38 insertions(+), 110 deletions(-) delete mode 100644 StockMate/StockMate/app/core/components/InventoryListSection.swift diff --git a/StockMate/StockMate/app/core/components/BarChartView.swift b/StockMate/StockMate/app/core/components/BarChartView.swift index a06f145..5fa4e35 100644 --- a/StockMate/StockMate/app/core/components/BarChartView.swift +++ b/StockMate/StockMate/app/core/components/BarChartView.swift @@ -9,7 +9,7 @@ import SwiftUI struct BarChartView: View { let values: [CGFloat] // 각 월별 비율값 (0~1) - let labels: [String] // 예: ["06", "07", "08", "09", "10"] + let labels: [String] // 예: ["10", "09", "08", "07", "06"] let amounts: [Int] // 예: [230000, 250000, 310000, 280000, 400000] @Binding var selectedMonth: String? diff --git a/StockMate/StockMate/app/core/components/CartCard.swift b/StockMate/StockMate/app/core/components/CartCard.swift index 556483f..6211f93 100644 --- a/StockMate/StockMate/app/core/components/CartCard.swift +++ b/StockMate/StockMate/app/core/components/CartCard.swift @@ -55,12 +55,6 @@ struct CartCard: View { if quantity == 0 { if let onAddToCart = onAddToCart { Button(action: onAddToCart) { -// Image(systemName: "cart.badge.plus") -// .font(.system(size: 18)) -// .foregroundColor(.Primary) -// .padding(10) -// .background(Color.Primary.opacity(0.1)) -// .clipShape(Circle()) Image("add_shopping_cart") .resizable() .scaledToFit() diff --git a/StockMate/StockMate/app/core/components/CustomButtonStyle.swift b/StockMate/StockMate/app/core/components/CustomButtonStyle.swift index fe42295..c25f344 100644 --- a/StockMate/StockMate/app/core/components/CustomButtonStyle.swift +++ b/StockMate/StockMate/app/core/components/CustomButtonStyle.swift @@ -26,10 +26,9 @@ struct CustomButtonStyle: ButtonStyle { configuration.label .frame(maxWidth: .infinity, minHeight: height) .background(color.opacity(configuration.isPressed ? 0.8 : 1)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .foregroundColor(.white) - .cornerRadius(cornerRadius) .font(.system(size: fontSize, weight: fontWeight)) -// .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) case .outlined(let color): @@ -37,13 +36,14 @@ struct CustomButtonStyle: ButtonStyle { .frame(maxWidth: .infinity, minHeight: height) .overlay( RoundedRectangle(cornerRadius: cornerRadius) - .stroke(color, lineWidth: 1.5) + .stroke(color, lineWidth: 1) ) .foregroundColor(color) .font(.system(size: fontSize, weight: fontWeight)) - .background(Color.white) - .cornerRadius(cornerRadius) -// .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.white) + ) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) } } diff --git a/StockMate/StockMate/app/core/components/InventoryListSection.swift b/StockMate/StockMate/app/core/components/InventoryListSection.swift deleted file mode 100644 index c8d6c19..0000000 --- a/StockMate/StockMate/app/core/components/InventoryListSection.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// 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/core/components/OrderRequestCardView.swift b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift index a328a43..f2fa6ab 100644 --- a/StockMate/StockMate/app/core/components/OrderRequestCardView.swift +++ b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift @@ -24,6 +24,7 @@ struct OrderRequestCardView: View { 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: { @@ -32,6 +33,7 @@ struct OrderRequestCardView: View { .frame(width: 64, height: 64) .cornerRadius(10) + // 이름 및 정보 VStack(alignment: .leading, spacing: 6) { Text(item.korName) .font(.system(size: 14, weight: .bold)) @@ -49,6 +51,7 @@ struct OrderRequestCardView: View { Spacer() + // 수량 컨트롤러 // 🪄 수량에 따른 3단계 분기 if quantity == 0 { Button(action: onAddToCart) { diff --git a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift index 72b16ad..4f1da94 100644 --- a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift @@ -119,10 +119,10 @@ struct LoginView: View { // MARK: - 유효성 검사 함수 private func isValidForm() -> Bool { -// emailError = isValidEmail(authViewModel.email) ? nil : "이메일 형식을 확인해주세요" -// pwError = authViewModel.password.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" -// return emailError == nil && pwError == nil - return true + emailError = isValidEmail(authViewModel.email) ? nil : "이메일 형식을 확인해주세요" + pwError = authViewModel.password.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" + return emailError == nil && pwError == nil +// return true } } diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift index 5e1f69b..88c9a68 100644 --- a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -59,11 +59,11 @@ final class HistoryViewModel: ObservableObject { /// ✅ 다음 페이지 로드 (무한 스크롤 등) func loadMoreIfNeeded(currentItem item: HistoryItem?) async { guard let item = item else { return } - let thresholdIndex = histories.index(histories.endIndex, offsetBy: -5) - if histories.firstIndex(where: { $0.id == item.id }) == thresholdIndex { - if currentPage + 1 < totalPages { - await fetchInOutHistory(page: currentPage + 1) - } + let threshold = max(histories.count - 5, 0) + if let currentIndex = histories.firstIndex(where: { $0.id == item.id }), + currentIndex >= threshold, + currentPage + 1 < totalPages { + await fetchInOutHistory(page: currentPage + 1) } } @@ -107,5 +107,4 @@ final class HistoryViewModel: ObservableObject { await fetchPaymentTransactions(page: transactionPage + 1) } } - } diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift index 25d8f04..af8bb03 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -154,7 +154,7 @@ struct OutgoingScanView: View { } // ✅ 부품 상세 조회 API 호출 - await partViewModel.fetchPartDetail(partId: partId) + await partViewModel.fetchPartDetail(partIds: partId) // ✅ 결과 출력 await MainActor.run { diff --git a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift index 5b4f73c..f644a0a 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -56,9 +56,7 @@ struct UsedPartListSheetView: View { .frame(maxWidth: .infinity, minHeight: 200) } else { LazyVStack { - ForEach( - $partStore.parts - ) { $part in // ✅ 바인딩으로 변경 ($ 붙임) + ForEach($partStore.parts) { $part in // ✅ 바인딩으로 변경 ($ 붙임) VStack(alignment: .leading, spacing: 6) { Text(part.categoryName) .font(.system(size: 12, weight: .semibold)) @@ -100,9 +98,7 @@ struct UsedPartListSheetView: View { // ✅ 수량 조절 버튼 (디자인 개선) HStack(spacing: 10) { Button { - if part.quantity > 1 { - part.quantity -= 1 - } + partStore.decreaseQuantityOrRemove(for: part) } label: { Image(systemName: "minus") .font(.system(size: 14, weight: .regular)) @@ -113,7 +109,7 @@ struct UsedPartListSheetView: View { .font(.system(size: 15, weight: .medium)) .frame(width: 20) Button { - part.quantity += 1 + partStore.increaseQuantity(for: part) } label: { Image(systemName: "plus") .font(.system(size: 14, weight: .regular)) diff --git a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift index 40b4507..bfa026e 100644 --- a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift +++ b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift @@ -116,14 +116,6 @@ struct ReceiveOrderRequest: Encodable { } -struct OrderSummary { - let firstPartName: String - let itemCount: Int - let totalPrice: Int - let createdAt: String -} - - // MARK: - API Call enum OrderApi { @@ -187,31 +179,4 @@ enum OrderApi { encoder: JSONParameterEncoder.default ) } - - // 주문 상세 정보 preview 데이터 가지고 오기 -// static func fetchOrderSummary(orderId: Int) async -> OrderSummary? { -// let result = await OrderRepositoryImpl().fetchOrderDetail(orderId: orderId) -// -// switch result { -// case .success(let order): // order == OrderResponseItem -// guard let first = order.orderItems.first else { return nil } -// return OrderSummary( -// firstPartName: first.partDetail.korName, -// itemCount: order.orderItems.count, -// totalPrice: order.totalPrice, -// createdAt: order.createdAt -// ) -// case .failure: -// return nil -// } -// } - static func fetchOrderSummary(orderId: Int) async -> OrderSummary? { - // 예시용 더미 데이터 - return OrderSummary( - firstPartName: "실린더 어셈블리-브레이크 마스터", - itemCount: 4, - totalPrice: 49720, - createdAt: "2025.11.03 14:34:35" - ) - } } diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift index 9c6d8f4..3310f9a 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift @@ -27,8 +27,6 @@ struct OrderInfoView: View { @State private var specificDate = Date() @State private var requestMessage: String = "" - @State private var navigateToSuccessPage = false - // ✅ 모달 관련 상태 @State private var showOrderSuccessModal = false @State private var navigateToOrderDetail = false @@ -45,15 +43,6 @@ struct OrderInfoView: View { } } } -// private var destinationView: some View { -// Group { -// if let id = orderViewModel.createdOrderId { -// OrderDetailView(orderId: id, orderViewModel: orderViewModel) -// } else { -// EmptyView() -// } -// } -// } func formattedShippingDate() -> String { let formatter = DateFormatter() @@ -101,7 +90,6 @@ struct OrderInfoView: View { Task { // 1) 서버에 반영된 장바구니를 먼저 비운다 (await) await cartViewModel.clearCart() -// navigateToSuccessPage = true // 2) cart가 비워진 후에 모달을 띄운다 // (모달을 띄우기 전에 createdOrderId는 orderViewModel에 이미 세팅되어 있어야 함) showOrderSuccessModal = true @@ -172,8 +160,6 @@ struct OrderInfoView: View { } } ) - - } @@ -286,9 +272,6 @@ extension OrderInfoView { .font(.system(size: 26, weight: .bold)) .foregroundColor(Color.white) } - - // Text("₩\(cartViewModel.depositBalance?.formatted() ?? "0")") - // .font(.system(size: 22, weight: .bold)) } } @@ -406,10 +389,6 @@ extension OrderInfoView { .frame(height: 70) .background(Color.Primary) } - - NavigationLink(destination: destinationView, isActive: $navigateToSuccessPage) { - EmptyView() - } } } } @@ -424,7 +403,6 @@ struct RadioButtonRow: View { Image(systemName: selected ? "circle.inset.filled" : "circle") .foregroundColor(selected ? .Primary : .gray) Text(title) - // Spacer() } .onTapGesture { action() } } diff --git a/StockMate/StockMate/app/feature/parts/data/PartStore.swift b/StockMate/StockMate/app/feature/parts/data/PartStore.swift index b769ce7..48436dc 100644 --- a/StockMate/StockMate/app/feature/parts/data/PartStore.swift +++ b/StockMate/StockMate/app/feature/parts/data/PartStore.swift @@ -40,4 +40,15 @@ final class PartStore: ObservableObject { objectWillChange.send() } } + + func decreaseQuantityOrRemove(for part: PartDetail) { + if let index = parts.firstIndex(where: { $0.id == part.id }) { + if parts[index].quantity > 1 { + parts[index].quantity -= 1 + } else { + parts.remove(at: index) + } + objectWillChange.send() + } + } } diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift index b3d6321..626dab2 100644 --- a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift @@ -87,7 +87,7 @@ final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputOb captureSession.stopRunning() } } - + // ✅ QR 감지 시 호출 func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { diff --git a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift index 03543f8..394dc62 100644 --- a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift +++ b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift @@ -47,11 +47,11 @@ final class PartViewModel: ObservableObject { } } - func fetchPartDetail(partId: Int) async { + func fetchPartDetail(partIds: Int) async { isLoading = true defer { isLoading = false } - let result = await repo.fetchPartDetail(partIds: [partId]) + let result = await repo.fetchPartDetail(partIds: [partIds]) switch result { case .success(let apiResp): if apiResp.success, let data = apiResp.data { diff --git a/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift index fb70701..90f28a2 100644 --- a/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift @@ -41,7 +41,7 @@ struct UserProfileView: View { .padding(.horizontal, 10) .padding(.bottom, 10) - Text("이에일") + Text("이메일") .font(.system(size: 14, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) From 26fe712098e07fbd36e9b3a30396ef03aceb075c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=ED=98=84=EC=95=84?= Date: Fri, 7 Nov 2025 12:33:25 +0900 Subject: [PATCH 22/22] =?UTF-8?q?[REFAC]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/components/ProfileFieldView.swift | 38 ++++++ .../ui/TransactionTypeListView.swift | 30 +++-- .../viewmodel/HistoryViewModel.swift | 2 +- .../app/feature/user/ui/UserProfileView.swift | 109 +----------------- 4 files changed, 63 insertions(+), 116 deletions(-) create mode 100644 StockMate/StockMate/app/core/components/ProfileFieldView.swift diff --git a/StockMate/StockMate/app/core/components/ProfileFieldView.swift b/StockMate/StockMate/app/core/components/ProfileFieldView.swift new file mode 100644 index 0000000..3f680d5 --- /dev/null +++ b/StockMate/StockMate/app/core/components/ProfileFieldView.swift @@ -0,0 +1,38 @@ +// +// ProfileFieldView.swift +// StockMate +// +// Created by Admin on 11/7/25. +// + +import SwiftUI + +struct ProfileFieldView: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 9) { + Text(label) + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .foregroundColor(Color.black) + + HStack { + Text(value) + .font(.system(size: 16, weight: .regular)) + .foregroundColor(.black) + Spacer() + } + .padding() + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.GrayMordern300, lineWidth: 1) + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + } + } +} diff --git a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift index 79ec4dc..622585a 100644 --- a/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift +++ b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift @@ -50,17 +50,25 @@ struct TransactionCard: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .center, spacing: 12) { - // 대표 이미지 - AsyncImage( - url: URL(string: item.orderItems?.first?.image ?? "") - ) { image in - image.resizable().scaledToFit() - } placeholder: { - Image("exchange") - .foregroundColor(Color.Primary) - } - .frame(width: 64, height: 64) - .cornerRadius(10) + + // PAY일 때만 부품 이미지 표시 + if item.transactionType == "PAY" { + // 대표 이미지 + AsyncImage(url: URL(string: item.orderItems?.first?.image ?? "")) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + } else { + Image("exchange") + .foregroundColor(Color.Primary) + .frame(width: 64, height: 64) + .cornerRadius(10) + } + + VStack(alignment: .leading, spacing: 5) { if item.transactionType == "PAY", diff --git a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift index 88c9a68..0eb95d7 100644 --- a/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -13,7 +13,7 @@ final class HistoryViewModel: ObservableObject { // MARK: - 입출고 히스토리 관련 @Published var histories: [HistoryItem] = [] @Published var isLoading = false - @Published var errorMessage: String? = nil // ✅ 추가 + @Published var errorMessage: String? @Published var currentPage = 0 @Published var totalPages = 1 diff --git a/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift index 90f28a2..9094fbe 100644 --- a/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift +++ b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift @@ -20,110 +20,11 @@ struct UserProfileView: View { .padding(.vertical, 32) VStack(spacing: 9) { - Text("대표자") - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .foregroundColor(Color.black) - - HStack { - Text(userViewModel.userInfo?.owner ?? "이름 없음") - .font(.system(size: 16, weight: .regular)) - .foregroundColor(.black) - Spacer() - } - .padding() - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.GrayMordern300, lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) - - Text("이메일") - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .foregroundColor(Color.black) - - HStack { - Text(userViewModel.userInfo?.email ?? "이메일 없음") - .font(.system(size: 16, weight: .regular)) - .foregroundColor(.black) - Spacer() - } - .padding() - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.GrayMordern300, lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) - - Text("지점") - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .foregroundColor(Color.black) - - HStack { - Text(userViewModel.userInfo?.storeName ?? "지점명 없음") - .font(.system(size: 16, weight: .regular)) - .foregroundColor(.black) - Spacer() - } - .padding() - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.GrayMordern300, lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) - - Text("주소") - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .foregroundColor(Color.black) - - HStack { - Text(userViewModel.userInfo?.address ?? "주소 없음") - .font(.system(size: 16, weight: .regular)) - .foregroundColor(.black) - Spacer() - } - .padding() - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.GrayMordern300, lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) - - Text("사업자등록번호") - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .foregroundColor(Color.black) - - HStack { - Text(userViewModel.userInfo?.businessNumber ?? "사업자등록번호 없음") - .font(.system(size: 16, weight: .regular)) - .foregroundColor(.black) - Spacer() - } - .padding() - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.GrayMordern300, lineWidth: 1) - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) + ProfileFieldView(label: "대표자", value: userViewModel.userInfo?.owner ?? "이름 없음") + ProfileFieldView(label: "이메일", value: userViewModel.userInfo?.email ?? "이메일 없음") + ProfileFieldView(label: "지점", value: userViewModel.userInfo?.storeName ?? "지점명 없음") + ProfileFieldView(label: "주소", value: userViewModel.userInfo?.address ?? "주소 없음") + ProfileFieldView(label: "사업자등록번호", value: userViewModel.userInfo?.businessNumber ?? "사업자등록번호 없음") Spacer()