diff --git a/StockMate/StockMate.xcodeproj/project.pbxproj b/StockMate/StockMate.xcodeproj/project.pbxproj index 76ad704..788b909 100644 --- a/StockMate/StockMate.xcodeproj/project.pbxproj +++ b/StockMate/StockMate.xcodeproj/project.pbxproj @@ -14,9 +14,22 @@ 84BB0A132E91FE0E00A08CD6 /* StockMate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StockMate.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 84BB0D3C2EB49C0900A08CD6 /* Exceptions for "StockMate" folder in "StockMate" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 84BB0A122E91FE0E00A08CD6 /* StockMate */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 84BB0A152E91FE0E00A08CD6 /* StockMate */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 84BB0D3C2EB49C0900A08CD6 /* Exceptions for "StockMate" folder in "StockMate" target */, + ); path = StockMate; sourceTree = ""; }; @@ -263,6 +276,8 @@ DEVELOPMENT_TEAM = 35TSG7VB2B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StockMate/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "QR 코드를 스캔하기 위해 카메라 접근이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -291,6 +306,8 @@ DEVELOPMENT_TEAM = 35TSG7VB2B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = StockMate/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "QR 코드를 스캔하기 위해 카메라 접근이 필요합니다."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/StockMate/StockMate/ContentView.swift b/StockMate/StockMate/ContentView.swift index d576e34..4d36f82 100644 --- a/StockMate/StockMate/ContentView.swift +++ b/StockMate/StockMate/ContentView.swift @@ -7,6 +7,46 @@ 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 { var body: some View { VStack { diff --git a/StockMate/StockMate/Info.plist b/StockMate/StockMate/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/StockMate/StockMate/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift index 06e87e3..27236a7 100644 --- a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift +++ b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift @@ -122,6 +122,7 @@ struct LoginView: View { 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/inventory/ui/IncomingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift index 83d0415..18e375a 100644 --- a/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift +++ b/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift @@ -8,52 +8,100 @@ import SwiftUI struct IncomingScanView: View { + @Environment(\.dismiss) private var dismiss + @State private var scannedCode: String? = nil + @State private var showAlert = false + @State private var alertMessage = "" + + @StateObject private var orderViewModel = OrderViewModel() // ✅ 뷰모델 추가 + var body: some View { - VStack(spacing: 30) { - // 상단 타이틀 - Text("입고 부품의 QR을 스캔해주세요") - .font(.headline) - .padding(.top, 30) - - // 스캔 영역 - ZStack { - RoundedRectangle(cornerRadius: 12) - .fill(Color.gray.opacity(0.2)) - .frame(width: 250, height: 250) - - RoundedRectangle(cornerRadius: 8) - .stroke(Color.blue, lineWidth: 3) - .frame(width: 220, height: 220) + ZStack { + // ✅ 1. 카메라 화면 (QR 스캐너) + QRScannerView(scannedCode: $scannedCode) + .ignoresSafeArea() + + // ✅ 2. 스캔 영역 가이드 박스 + VStack { + Text("입고 부품의 QR을 스캔해주세요") + .font(.headline) + .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.blue, lineWidth: 3) + .frame(width: 220, height: 220) + } + .padding(.bottom, 180) + + Spacer() + + // ✅ 직접 등록 버튼 + Button(action: { + dismiss() + }) { + Text("직접 등록 하기") + .fontWeight(.semibold) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding() + .background(Color.white) + .cornerRadius(10) + .shadow(color: .gray.opacity(0.3), radius: 2, x: 0, y: 2) + } + .padding(.horizontal, 40) + .padding(.bottom, 40) } - .padding(.top, 20) - - Spacer() - - // 직접 등록 버튼 - Button(action: { - print("직접 등록 tapped") - }) { - Text("직접 등록 하기") - .fontWeight(.semibold) - .foregroundColor(.black) - .frame(maxWidth: .infinity) + + // ✅ 로딩 표시 + if orderViewModel.isLoading { + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("입고 처리 중...") .padding() - .background(Color.white) + .background(.ultraThinMaterial) .cornerRadius(10) - .shadow(color: .gray.opacity(0.3), radius: 2, x: 0, y: 2) } - .padding(.horizontal, 40) - .padding(.bottom, 40) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray.opacity(0.1)) + .alert("입고 처리 결과", isPresented: $showAlert) { + Button("확인") { + dismiss() + } + } message: { + Text(alertMessage) + } + .onChange(of: scannedCode) { newValue in + guard let code = newValue, !code.isEmpty else { return } + Task { + await handleScannedCode(code) + } + } .navigationTitle("입고 부품 등록") .navigationBarTitleDisplayMode(.inline) } -} - -#Preview { - NavigationStack { - IncomingScanView() + + private func handleScannedCode(_ code: String) async { + await MainActor.run { + orderViewModel.isLoading = true + } + + let result = await orderViewModel.receiveOrder(orderNumber: code) + await MainActor.run { + orderViewModel.isLoading = false + switch result { + case .success(let message): + alertMessage = message + case .failure(let error): + alertMessage = error.message + } + showAlert = true + } } } diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift index 35545c0..9641e2e 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(IncomingScanView())), ("입고 처리", false, "InvIncoming", AnyView(IncomingScanView())), - ("사용 처리", true, "InvUse", AnyView(IncomingScanView())), + ("사용 처리", true, "InvUse", AnyView(OutgoingScanView())), ] var body: some View { diff --git a/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift new file mode 100644 index 0000000..506c0fa --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -0,0 +1,120 @@ +// +// OutgoingScanView.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + + +import SwiftUI + +struct OutgoingScanView: View { + @Environment(\.dismiss) private var dismiss + @State private var scannedCode: String? = nil + @State private var showAlert = false + @State private var alertMessage = "" + + @StateObject private var partViewModel = PartViewModel() // ✅ ViewModel 추가 + + var body: some View { + ZStack { + // ✅ 카메라 미리보기 (QR 스캐너) + QRScannerView(scannedCode: $scannedCode) + .ignoresSafeArea() + + // ✅ 스캔 가이드 및 UI 오버레이 + VStack { + Text("사용할 부품의 QR을 스캔해주세요") + .font(.headline) + .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() + }) { + Text("직접 입력 하기") + .fontWeight(.semibold) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding() + .background(Color.white) + .cornerRadius(10) + .shadow(color: .gray.opacity(0.3), radius: 2, x: 0, y: 2) + } + .padding(.horizontal, 40) + .padding(.bottom, 40) + } + + // ✅ 로딩 인디케이터 + 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) + } + } + .navigationTitle("부품 사용 처리") + .navigationBarTitleDisplayMode(.inline) + } + + // ✅ 스캔된 코드로 출고 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 + } + } +} + +#Preview { + NavigationStack { + OutgoingScanView() + } +} diff --git a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift index 7032db0..ef3d651 100644 --- a/StockMate/StockMate/app/feature/orders/data/OrderApi.swift +++ b/StockMate/StockMate/app/feature/orders/data/OrderApi.swift @@ -104,14 +104,27 @@ struct OrderItems: Encodable { } // 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 let totalPrice: Int - let orderStatus: String + let paymentType: String // ✅ 서버 필드와 맞춤 +} + +// ✅ 입고 처리 요청 API +struct ReceiveOrderRequest: Encodable { + let orderNumber: String } + // MARK: - API Call enum OrderApi { @@ -164,5 +177,15 @@ enum OrderApi { return ApiClient.shared.request(url, method: .put) .validate() // ✅ 서버 상태코드 확인 } - + + // ✅ 입고 처리 API + static func receiveOrder(_ requestBody: ReceiveOrderRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/receive" + return ApiClient.shared.request( + url, + method: .post, + parameters: requestBody, + encoder: JSONParameterEncoder.default + ) + } } diff --git a/StockMate/StockMate/app/feature/orders/data/OrderRepositoryImpl.swift b/StockMate/StockMate/app/feature/orders/data/OrderRepositoryImpl.swift index 0f4d42f..c670736 100644 --- a/StockMate/StockMate/app/feature/orders/data/OrderRepositoryImpl.swift +++ b/StockMate/StockMate/app/feature/orders/data/OrderRepositoryImpl.swift @@ -90,4 +90,17 @@ final class OrderRepositoryImpl: OrderRepositoryProtocol { } } + func receiveOrder(orderNumber: String) async -> AppResult { + let request = OrderApi.receiveOrder(.init(orderNumber: orderNumber)) + let result = await safeApi(request, decodeTo: ApiResponse.self) + + switch result { + case .success(let response): + print("✅ 입고 처리 성공:", response) + return .success(response.data ?? "입고 처리가 완료되었습니다.") + case .failure(let error): + print("❌ 입고 처리 실패:", error.message) + return .failure(error) + } + } } diff --git a/StockMate/StockMate/app/feature/orders/domain/OrderRepositoryProtocol.swift b/StockMate/StockMate/app/feature/orders/domain/OrderRepositoryProtocol.swift index 9b98ce6..81be290 100644 --- a/StockMate/StockMate/app/feature/orders/domain/OrderRepositoryProtocol.swift +++ b/StockMate/StockMate/app/feature/orders/domain/OrderRepositoryProtocol.swift @@ -24,6 +24,9 @@ protocol OrderRepositoryProtocol { // 주문 생성 func createOrder(request: OrderRequest) async -> AppResult - + // 주문 취소 func cancelOrder(orderId: Int) async -> AppResult + + // 입고 처리 + func receiveOrder(orderNumber: String) async -> AppResult } diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift index a963578..0e4c5de 100644 --- a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift +++ b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift @@ -25,27 +25,21 @@ struct OrderDetailView: View { } .frame(maxWidth: .infinity, alignment: .center) - + + // ✅ 주문 정보 VStack(alignment: .leading, spacing: 6) { Text(formatDate(order.createdAt)) .font(.system(size: 15, weight: .semibold)) .padding(.bottom, 4) - // ✅ 주문 정보 + infoRow("주문번호", order.orderNumber) + .padding(.bottom, 4) + + HStack(alignment: .top, spacing: 6){ - VStack(alignment: .leading){ - Text("주문번호") - .font(.system(size: 14)) - .padding(.bottom, 4) - Text("상태") .font(.system(size: 14)) - } - - VStack(alignment: .leading){ - Text(order.orderNumber) - .font(.system(size: 14)) - + Spacer() Text(statusText(order.orderStatus)) .font(.system(size: 13, weight: .semibold)) .padding(.horizontal, 10) @@ -53,7 +47,6 @@ struct OrderDetailView: View { .background(statusBdColor(order.orderStatus)) .foregroundColor(statusColor(order.orderStatus)) .cornerRadius(12) - }.padding(.leading) } } @@ -63,7 +56,23 @@ struct OrderDetailView: View { .cornerRadius(16) .shadow(color: .black.opacity(0.05), radius: 3, y: 2) - + // 승인 반려인 경우에만 반려메세지 칸 생성 + if order.orderStatus == "REJECTED" { + VStack(alignment: .leading, spacing: 6) { + Text("반려 메세지") + .font(.system(size: 15, weight: .semibold)) + .padding(.bottom, 4) + Text(order.rejectedMessage ?? "-") + .font(.system(size: 14)) + + } + .frame(maxWidth: .infinity, alignment: .leading) // ✅ 여기도 추가 + .padding(.all, 20) + .background(Color.white) + .cornerRadius(16) + .shadow(color: .black.opacity(0.05), radius: 3, y: 2) + } + // ✅ 배송 정보 VStack(alignment: .leading, spacing: 6) { Text("배송정보") @@ -83,15 +92,30 @@ struct OrderDetailView: View { return "-" }() infoRow("운송장번호", trackingText) + } + .frame(maxWidth: .infinity, alignment: .leading) // ✅ 여기도 추가 + .padding(.all, 20) + .background(Color.white) + .cornerRadius(16) + .shadow(color: .black.opacity(0.05), radius: 3, y: 2) + + + // 요청사항 따로 빼기 + VStack(alignment: .leading, spacing: 6) { + Text("요청사항") + .font(.system(size: 15, weight: .semibold)) + .padding(.bottom, 4) + + Text(order.etc ?? "") + .font(.system(size: 14)) - infoRow("요청사항", order.etc ?? "") } .frame(maxWidth: .infinity, alignment: .leading) // ✅ 여기도 추가 .padding(.all, 20) .background(Color.white) .cornerRadius(16) .shadow(color: .black.opacity(0.05), radius: 3, y: 2) - + // ✅ 주문 상품 OrderSectionCard { diff --git a/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift b/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift index dccd03f..447274b 100644 --- a/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift +++ b/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift @@ -78,6 +78,35 @@ final class OrderViewModel: ObservableObject { } isLoading = false } + + func receiveOrder(orderNumber: String) async -> AppResult { + isLoading = true + defer { isLoading = false } + + let result = await repository.receiveOrder(orderNumber: orderNumber) + switch result { + case .success(let message): + print("✅ 입고 처리 성공:", message) + await loadOrders() + return .success(message) + case .failure(let error): + errorMessage = error.message + return .failure(error) + } + } +// func receiveOrder(orderNumber: String) async { +// isLoading = true +// defer { isLoading = false } +// +// let result = await repository.receiveOrder(orderNumber: orderNumber) +// switch result { +// case .success(let message): +// print("✅ 입고 처리 성공:", message) +// await loadOrders() // 리스트 갱신 +// case .failure(let error): +// errorMessage = error.message +// } +// } } diff --git a/StockMate/StockMate/app/feature/parts/data/PartApi.swift b/StockMate/StockMate/app/feature/parts/data/PartApi.swift new file mode 100644 index 0000000..9c5070d --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -0,0 +1,32 @@ +// +// PartApi.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + + +import Foundation +import Alamofire + +// ✅ 요청 모델 +struct ReleaseItemRequest: Encodable { + let partCode: String + let quantity: Int +} + +// ✅ API 정의 +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] } + ] + 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 new file mode 100644 index 0000000..7ac1c08 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift @@ -0,0 +1,16 @@ +// +// PartRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import Foundation +import Alamofire + +final class PartRepositoryImpl: PartRepositoryProtocol { + func releaseParts(items: [ReleaseItemRequest]) async -> AppResult> { + let dataReq = PartApi.releaseParts(items: items) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift new file mode 100644 index 0000000..f3a939e --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift @@ -0,0 +1,12 @@ +// +// PartRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import Foundation + +protocol PartRepositoryProtocol { + func releaseParts(items: [ReleaseItemRequest]) async -> AppResult> +} diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift new file mode 100644 index 0000000..761468f --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift @@ -0,0 +1,36 @@ +// +// QRScannerView.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import SwiftUI + +struct QRScannerView: UIViewControllerRepresentable { + @Binding var scannedCode: String? + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator: NSObject, QRScannerDelegate { + let parent: QRScannerView + + init(_ parent: QRScannerView) { + self.parent = parent + } + + func didScanQRCode(_ code: String) { + parent.scannedCode = code + } + } +} diff --git a/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift new file mode 100644 index 0000000..f338a55 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift @@ -0,0 +1,69 @@ +// +// QRScannerViewController.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import UIKit +import AVFoundation + +protocol QRScannerDelegate: AnyObject { + func didScanQRCode(_ code: String) +} + +final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + weak var delegate: QRScannerDelegate? + + private var captureSession: AVCaptureSession! + private var previewLayer: AVCaptureVideoPreviewLayer! + + override func viewDidLoad() { + super.viewDidLoad() + setupCamera() + } + + private func setupCamera() { + captureSession = AVCaptureSession() + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + captureSession.canAddInput(videoInput) + else { + print("카메라 입력 불가") + return + } + + captureSession.addInput(videoInput) + + let metadataOutput = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(metadataOutput) else { + print("출력 연결 불가") + return + } + + captureSession.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = [.qr] + + previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.addSublayer(previewLayer) + + // ⚠️ 백그라운드에서 실행 + DispatchQueue.global(qos: .userInitiated).async { + self.captureSession.startRunning() + } + } + + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let stringValue = metadataObject.stringValue { + captureSession.stopRunning() + delegate?.didScanQRCode(stringValue) + } + } +} + diff --git a/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift new file mode 100644 index 0000000..fc3fecd --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift @@ -0,0 +1,44 @@ +// +// PartViewModel.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + + +import SwiftUI + +@MainActor +final class PartViewModel: ObservableObject { + @Published var isLoading = false + @Published var message: String = "" + @Published var shouldGoToLogin = false + + private let repo: PartRepositoryProtocol + + init(repo: PartRepositoryProtocol = PartRepositoryImpl()) { + self.repo = repo + } + + func releaseParts(items: [ReleaseItemRequest]) async -> AppResult { + isLoading = true + defer { isLoading = false } + + let result = await repo.releaseParts(items: items) + switch result { + case .success(let apiResp): + message = apiResp.message + if let data = apiResp.data { + return .success(data) + } else { + return .failure(AppError(code: apiResp.status, message: apiResp.message, underlying: nil)) + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + return .failure(err) + } + } +} \ No newline at end of file