diff --git a/.github/coderabbit.yaml b/.github/coderabbit.yaml new file mode 100644 index 0000000..6c8789f --- /dev/null +++ b/.github/coderabbit.yaml @@ -0,0 +1,15 @@ +language: "ko-KR" + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: false + review_status: true + auto_review: + enabled: true + drafts: false + branches: + include: + - main + - dev diff --git a/.github/workflows/close-dev-issue.yml b/.github/workflows/close-dev-issue.yml new file mode 100644 index 0000000..e137bc5 --- /dev/null +++ b/.github/workflows/close-dev-issue.yml @@ -0,0 +1,45 @@ +name: Auto Close Issues on dev merge + +permissions: + issues: write + +on: + pull_request: + types: [closed] + +jobs: + close-issues: + if: > + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'dev' + runs-on: ubuntu-latest + + steps: + - name: Close linked issues + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prBody = context.payload.pull_request.body; + const issueLines = prBody.split('\n'); + + // 클로징 키워드가 있는 줄에서만 이슈 번호 추출 + const closingKeywords = ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved']; + const issuePattern = /#(\d+)/g; + + for (const line of issueLines) { + const lower = line.toLowerCase(); + if (closingKeywords.some(k => lower.includes(k))) { + let match; + while ((match = issuePattern.exec(line)) !== null) { + const issue_number = parseInt(match[1]); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + state: 'closed' + }); + console.log(`✅ Closed issue #${issue_number}`); + } + } + } diff --git a/.gitignore b/.gitignore index ca4915e..703c1ad 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ DerivedData/ Pods/ # Swift Package Manager -Packages/ \ No newline at end of file +Packages/ + +# Xcode user state files +*.xcuserstate +*.xcuserdata/ diff --git a/StockMate/.DS_Store b/StockMate/.DS_Store index df0cfd3..7939a51 100644 Binary files a/StockMate/.DS_Store and b/StockMate/.DS_Store differ diff --git a/StockMate/StockMate.xcodeproj/project.pbxproj b/StockMate/StockMate.xcodeproj/project.pbxproj index b2c2493..97cbaa9 100644 --- a/StockMate/StockMate.xcodeproj/project.pbxproj +++ b/StockMate/StockMate.xcodeproj/project.pbxproj @@ -6,13 +6,30 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 84BB0A832E94E09700A08CD6 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 84BB0A822E94E09700A08CD6 /* Alamofire */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 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 = ""; }; @@ -23,6 +40,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 84BB0A832E94E09700A08CD6 /* Alamofire in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,6 +83,7 @@ ); name = StockMate; packageProductDependencies = ( + 84BB0A822E94E09700A08CD6 /* Alamofire */, ); productName = StockMate; productReference = 84BB0A132E91FE0E00A08CD6 /* StockMate.app */; @@ -94,6 +113,9 @@ ); mainGroup = 84BB0A0A2E91FE0E00A08CD6; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 84BB0A812E94E09700A08CD6 /* XCRemoteSwiftPackageReference "Alamofire" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 84BB0A142E91FE0E00A08CD6 /* Products */; projectDirPath = ""; @@ -249,10 +271,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + 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; @@ -263,8 +289,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.StockMate; + PRODUCT_BUNDLE_IDENTIFIER = com.hyuna.StockMate; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -276,10 +303,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + 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; @@ -290,8 +321,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.StockMate; + PRODUCT_BUNDLE_IDENTIFIER = com.hyuna.StockMate; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -320,6 +352,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 84BB0A812E94E09700A08CD6 /* XCRemoteSwiftPackageReference "Alamofire" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Alamofire/Alamofire.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.10.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 84BB0A822E94E09700A08CD6 /* Alamofire */ = { + isa = XCSwiftPackageProductDependency; + package = 84BB0A812E94E09700A08CD6 /* XCRemoteSwiftPackageReference "Alamofire" */; + productName = Alamofire; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 84BB0A0B2E91FE0E00A08CD6 /* Project object */; } diff --git a/StockMate/StockMate.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate b/StockMate/StockMate.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 76297c0..0000000 Binary files a/StockMate/StockMate.xcodeproj/project.xcworkspace/xcuserdata/admin.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/StockMate/StockMate.xcodeproj/xcuserdata/admin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/StockMate/StockMate.xcodeproj/xcuserdata/admin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..52bdcb3 --- /dev/null +++ b/StockMate/StockMate.xcodeproj/xcuserdata/admin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/StockMate/StockMate/ContentView.swift b/StockMate/StockMate/ContentView.swift index 5177993..5787aab 100644 --- a/StockMate/StockMate/ContentView.swift +++ b/StockMate/StockMate/ContentView.swift @@ -8,17 +8,29 @@ import SwiftUI 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("Hello, world!") + VStack(spacing: 20) { + Text(address) + .font(.title3) + .multilineTextAlignment(.center) + .padding() + + Button("주소 검색") { + showWebView.toggle() + } + .font(.headline) + .buttonStyle(.borderedProminent) + } + .sheet(isPresented: $showWebView) { + KakaoZipCodeView(address: $address) } - .padding() } } + #Preview { ContentView() } 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/StockMateApp.swift b/StockMate/StockMate/app/StockMateApp.swift index 5b8644e..da169f7 100644 --- a/StockMate/StockMate/app/StockMateApp.swift +++ b/StockMate/StockMate/app/StockMateApp.swift @@ -9,9 +9,26 @@ import SwiftUI @main struct StockMateApp: App { + @StateObject private var authViewModel = AuthViewModel() + @State private var isLoading = true // 인트로 상태 + var body: some Scene { WindowGroup { - ContentView() + + Group { + if isLoading { + IntroView() // 로고만 보여주는 화면 + } else { + AppNavHost() + .environmentObject(authViewModel) + } + } + .onAppear { + Task { + try await Task.sleep(nanoseconds: 1_500_000_000) // 1.5초 + isLoading = false + } + } } } } diff --git a/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeVC.swift new file mode 100644 index 0000000..04e3245 --- /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..2efd44f --- /dev/null +++ b/StockMate/StockMate/app/core/KakaoPostcode/KakaoZipCodeView.swift @@ -0,0 +1,31 @@ +// +// KakaoZipCodeView.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + + +import SwiftUI +import WebKit + +// SwiftUI에서 UIKit 기반의 Kakao 우편번호 검색 화면을 사용하기 위한 래퍼 뷰 +// UIViewControllerRepresentable을 통해 KakaoZipCodeVC를 SwiftUI에 통합 +struct KakaoZipCodeView: UIViewControllerRepresentable { + // 선택된 주소 값을 SwiftUI와 바인딩 + @Binding var address: String + + // KakaoZipCodeVC 생성 및 초기 설정 + func makeUIViewController(context: Context) -> KakaoZipCodeVC { + let vc = KakaoZipCodeVC() + + // 주소가 선택되었을 때 SwiftUI 바인딩 변수로 전달 + vc.onAddressSelected = { selectedAddress in + address = selectedAddress + } + return vc + } + + // UIViewController 상태 갱신 (현재는 별도 갱신 로직 없음) + 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..b6d74f8 --- /dev/null +++ b/StockMate/StockMate/app/core/KakaoPostcode/ViewController.swift @@ -0,0 +1,54 @@ +// +// ViewController.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + +import UIKit + +// 기본 테스트용 ViewController +// 버튼을 눌러 Kakao 우편번호 검색 화면(KakaoZipCodeVC)을 표시함 +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/common/AppResult.swift b/StockMate/StockMate/app/core/common/AppResult.swift new file mode 100644 index 0000000..848ff05 --- /dev/null +++ b/StockMate/StockMate/app/core/common/AppResult.swift @@ -0,0 +1,19 @@ +// +// AppResult.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation + +enum AppResult { + case success(T) + case failure(AppError) +} + +struct AppError: Error { + let code: Int? + let message: String + let underlying: Error? +} diff --git a/StockMate/StockMate/app/core/common/SafeApi.swift b/StockMate/StockMate/app/core/common/SafeApi.swift new file mode 100644 index 0000000..0588f9c --- /dev/null +++ b/StockMate/StockMate/app/core/common/SafeApi.swift @@ -0,0 +1,64 @@ +// +// SafeApi.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +func httpCodeToMessage(_ code: Int?) -> String { + guard let code = code else { return "알 수 없는 오류가 발생했어요." } + switch code { + case 400: return "요청 형식을 다시 확인해주세요." + case 401: return "로그인이 필요해요. 다시 로그인해주세요." + case 403: return "접근 권한이 없어요." + case 404: return "요청하신 정보를 찾을 수 없어요." + case 408: return "요청 시간이 초과되었어요. 다시 시도해주세요." + case 409: return "이미 존재하거나 충돌이 발생했어요." + case 422: return "입력값을 다시 확인해주세요." + case 429: return "요청이 많아요. 잠시 후 다시 시도해주세요." + case 500: return "서버 내부 오류가 발생했어요." + case 502: return "게이트웨이 오류가 발생했어요." + case 503: return "현재 서버가 점검 중이에요. 잠시 후 다시 시도해주세요." + case 504: return "서버 응답 시간이 초과되었어요." + case 500...599: return "서버 오류가 발생했어요. 잠시 후 다시 시도해주세요." + default: return "문제가 발생했어요. 잠시 후 다시 시도해주세요." + } +} + +@discardableResult +func safeApi(_ request: DataRequest, decodeTo: T.Type) async -> AppResult { + do { + let response = try await request.serializingDecodable(T.self).response + if let status = response.response?.statusCode { + if (200..<300).contains(status), let value = response.value { + return .success(value) + } else { + // 서버가 에러 body에 ApiResponse.message 를 넣어주는 경우 파싱 시도 + if let data = response.data, + let serverErr = try? JSONDecoder().decode(ApiResponse.self, from: data), + !serverErr.message.isEmpty { + return .failure(AppError(code: status, message: serverErr.message, underlying: nil)) + } + return .failure(AppError(code: status, message: httpCodeToMessage(status), underlying: nil)) + } + } else { + return .failure(AppError(code: nil, message: "응답이 유효하지 않습니다.", underlying: nil)) + } + } catch let afError as AFError { + // URLError 기반 메시지 분기 (UnknownHost / Timeout 등) + if let urlErr = afError.underlyingError as? URLError { + switch urlErr.code { + case .notConnectedToInternet: return .failure(AppError(code: nil, message: "인터넷 연결을 확인해주세요.", underlying: afError)) + case .timedOut: return .failure(AppError(code: nil, message: "응답이 지연되고 있어요. 잠시 후 다시 시도해주세요.", underlying: afError)) + default: return .failure(AppError(code: nil, message: afError.errorDescription ?? "네트워크 오류가 발생했어요.", underlying: afError)) + } + } + return .failure(AppError(code: nil, message: afError.errorDescription ?? "네트워크 오류가 발생했어요.", underlying: afError)) + } catch { + return .failure(AppError(code: nil, message: "알 수 없는 오류가 발생했어요.", underlying: error)) + } +} + diff --git a/StockMate/StockMate/app/core/common/Validators.swift b/StockMate/StockMate/app/core/common/Validators.swift new file mode 100644 index 0000000..61c264c --- /dev/null +++ b/StockMate/StockMate/app/core/common/Validators.swift @@ -0,0 +1,24 @@ +// +// Validators.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation + +func isValidEmail(_ email: String) -> Bool { + let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: email) +} + +func isValidPassword(_ pw: String) -> Bool { + guard pw.count >= 8 else { return false } + return pw.range(of: "[A-Za-z]", options: .regularExpression) != nil && + pw.range(of: "[0-9]", options: .regularExpression) != nil +} + +func isValidBizNo(_ no: String) -> Bool { + let regex = "^\\d{3}-\\d{2}-\\d{5}$" + return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: no) +} diff --git a/StockMate/StockMate/app/core/components/AlertModal.swift b/StockMate/StockMate/app/core/components/AlertModal.swift new file mode 100644 index 0000000..88db275 --- /dev/null +++ b/StockMate/StockMate/app/core/components/AlertModal.swift @@ -0,0 +1,155 @@ +// +// 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 + + // 버튼 레이아웃 타입 정의 + 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개 + AlertModal( + icon: Image("SuccessIllust"), + title: "등록 완료!", + message: "입고 부품 등록이 완료되었습니다.", + primaryButtonTitle: "확인", + primaryAction: {} + ) + AlertModal( + icon: Image("SuccessIllust"), + title: "출고 완료!", + message: "사용 처리가 완료되었습니다.", + primaryButtonTitle: "확인", + primaryAction: {} + ) + + // 아이콘 없이 버튼 2개 (가로 배치) + AlertModal( + title: "주문 취소", + message: "주문을 취소하시겠습니까?", + primaryButtonTitle: "예", + primaryAction: {}, + secondaryButtonTitle: "아니오", + secondaryAction: {}, + buttonLayout: .horizontal + ) + AlertModal( + title: "로그아웃", + message: "로그아웃 하시겠습니까?", + primaryButtonTitle: "로그아웃", + primaryAction: {}, + secondaryButtonTitle: "취소", + secondaryAction: {}, + buttonLayout: .horizontal + ) + + // 주문 완료 알림 (세로 버튼 배치) + 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/BarChartView.swift b/StockMate/StockMate/app/core/components/BarChartView.swift new file mode 100644 index 0000000..852180d --- /dev/null +++ b/StockMate/StockMate/app/core/components/BarChartView.swift @@ -0,0 +1,93 @@ +// +// BarChartView.swift +// StockMate +// +// Created by Admin on 11/2/25. +// + +import SwiftUI + +struct BarChartView: View { + let values: [CGFloat] // 각 월별 비율값 (0~1) + let labels: [String] // 예: ["10", "09", "08", "07", "06"] + 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.last ?? "" + let activeMonth = selectedMonth ?? defaultMonth + + VStack(alignment: .leading, spacing: 12) { + // 막대 그래프 + GeometryReader { geometry in + let chartHeight = geometry.size.height * 0.88 // 상하 여백 고려 + let totalWidth = geometry.size.width + let barCount = CGFloat(reversedValues.count) + let barWidth: CGFloat = 35 + let spacing = max((totalWidth - (barWidth * barCount)) / (barCount + 1), 6) + + HStack(alignment: .bottom, spacing: spacing) { + ForEach(reversedValues.indices, id: \.self) { i in + VStack { + RoundedRectangle(cornerRadius: 10) + .fill(activeMonth == displayLabels[i] ? Color.Primary : Color.LightBlue04) + .frame(width: barWidth, height: max(chartHeight * reversedValues[i], 8)) // 최소 높이 보장 + .onTapGesture { + selectedMonth = (selectedMonth == displayLabels[i]) ? nil : displayLabels[i] + } + + Text(displayLabels[i]) + .font(.system(size: 13, weight: activeMonth == displayLabels[i] ? .semibold : .light)) // 선택된 막대는 글씨 bold + .padding(.top, 3) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 163) // 전체 그래프 영역 높이 확장 + .padding(.bottom, 7) + + Rectangle() + .fill(Color.textGray2) + .frame(height: 0.8) // Divider보다 살짝 두껍게 + .padding(.horizontal, 4) + + + // 하단 "n월 지출금액 ooo원" 표시 + if let index = displayLabels.firstIndex(of: activeMonth) { + HStack { + Text("\(displayLabels[index]) 지출 현황") + .font(.system(size: 15, weight: .regular)) + Spacer() + Text("\(reversedAmounts[index].formatted())원") + .font(.system(size: 17, weight: .bold)) + .foregroundColor(Color.Primary) + } + .padding(.horizontal,4) + .padding(.vertical, 2) + } + } + .frame(maxWidth: .infinity) + // 초기 로드 시 최신월 자동 선택 + .onAppear { + if selectedMonth == nil { + selectedMonth = defaultMonth + } + } + } +} diff --git a/StockMate/StockMate/app/core/components/BottomToast.swift b/StockMate/StockMate/app/core/components/BottomToast.swift new file mode 100644 index 0000000..dd9f586 --- /dev/null +++ b/StockMate/StockMate/app/core/components/BottomToast.swift @@ -0,0 +1,57 @@ +// +// BottomToast.swift +// StockMate +// +// Created by Admin on 11/10/25. +// + +import SwiftUI + +// 화면 하단에 토스트 알림 뷰 +struct BottomToast: View { + let message: String + @Binding var isVisible: Bool + var iconName: String = "toastlogo" + var iconColor: Color = .white + var backgroundColor: Color = Color(hex: "4CAF50") + var duration: Double = 2.2 + + var body: some View { + VStack { + Spacer() + + // 토스트가 표시될 때만 렌더링 + if isVisible { + HStack(spacing: 10) { + Image(iconName) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + Text(message) + .font(.system(size: 14, weight: .light)) + .foregroundColor(.black) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .padding(.horizontal, 22) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 9999) + .fill(backgroundColor) + ) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 4) + .padding(.bottom, 60) + .padding(.horizontal, 50) + .onAppear { // 토스트가 나타날 때 타이머 시작 + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + withAnimation(.easeInOut(duration: 0.25)) { + isVisible = false + } + } + } + } + } + .animation(.easeInOut(duration: 0.25), value: isVisible) + } +} diff --git a/StockMate/StockMate/app/core/components/CartCard.swift b/StockMate/StockMate/app/core/components/CartCard.swift new file mode 100644 index 0000000..b8157e9 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CartCard.swift @@ -0,0 +1,64 @@ +// +// CartCard.swift +// StockMate +// +// Created by Admin on 10/27/25. +// + +import SwiftUI + +struct CartCard: View { + let item: CartItem + let quantity: Int + let onIncrease: () -> Void + let onDecrease: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.categoryName ?? "") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + + Divider().frame(height: 0.2).background(Color.textGray2) + + HStack(alignment: .center, spacing: 12) { + AsyncImage(url: URL(string: item.image ?? "")) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 6) { + Text(item.brand ?? "") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + .lineLimit(2) + + Text("\((item.trim ?? "")) / \((item.model ?? ""))") + .font(.system(size: 13)) + .foregroundColor(.gray) + .lineLimit(1) + + Text("\(item.price ?? 0)원") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.black) + + } + + Spacer() + + QuantityControlView( + quantity: quantity, + onIncrease: onIncrease, + onDecrease: onDecrease + ) + } + } + .padding() + .background(Color.white) + .cornerRadius(14) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + } +} diff --git a/StockMate/StockMate/app/core/components/CartInfoCard.swift b/StockMate/StockMate/app/core/components/CartInfoCard.swift new file mode 100644 index 0000000..6c3c0c6 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CartInfoCard.swift @@ -0,0 +1,85 @@ +// +// CartInfoCard.swift +// StockMate +// +// Created by Admin on 10/27/25. +// + +import SwiftUI + +struct CartInfoCard: View { + let item: CartItem + let quantity: Int + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.categoryName ?? "") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + + Divider().frame(height: 0.2).background(Color.textGray2) + + HStack(alignment: .center, spacing: 12) { + AsyncImage(url: URL(string: item.image ?? "")) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + + VStack(alignment: .leading, spacing: 6) { + Text(item.brand ?? "") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + .lineLimit(2) + + HStack{ + Text("\((item.trim ?? "")) / \((item.model ?? ""))") + .font(.system(size: 13)) + .foregroundColor(.gray) + .lineLimit(1) + + Spacer() + + Text("\((item.price ?? 0) * item.amount)원") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.black) + .lineLimit(1) + + } + Text("\(item.price ?? 0)원 / \(item.amount)개") + .font(.system(size: 13)) + .foregroundColor(.gray) + .lineLimit(1) + } + } + } + .padding() + .background(Color.white) + .cornerRadius(14) + } +} + +#Preview { + CartInfoCard( + item: CartItem( + cartItemId: 1, + partId: 101, + amount: 2, + partName: "실린더 어셈블리 브레이크 마스터", + categoryName: "엔진/미션", + brand: "실린더 어셈블리 브레이크 마스터", + model: "아반떼 MD", + trim: "중형", + price: 60000, + stock: 30, + image: "" + ), + quantity: 2 + ) + .previewLayout(.sizeThatFits) + .padding() + .background(Color.Light) +} diff --git a/StockMate/StockMate/app/core/components/CartSummaryBar.swift b/StockMate/StockMate/app/core/components/CartSummaryBar.swift new file mode 100644 index 0000000..b4aea61 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CartSummaryBar.swift @@ -0,0 +1,50 @@ +// +// CartSummaryBar.swift +// StockMate +// +// Created by Admin on 10/26/25. +// + +import Foundation +import SwiftUI + +struct CartSummaryBar: View { + @ObservedObject var cartVM: CartViewModel + + var body: some View { + VStack { + Spacer() + + if let cart = cartVM.cart, + !cart.items.isEmpty { + NavigationLink(destination: OrderCartView(cartViewModel: cartVM)) { + HStack(spacing: 12) { + Circle() + .fill(Color.white) + .frame(width: 20, height: 20) + .overlay( + Text("\(cart.items.count)") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.Primary) + ) + + Text("장바구니 보기") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + + Spacer() + + Text("\(cart.totalPrice ?? 0)원") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + } + .padding(.horizontal, 30) + .frame(height: 60) + .background(Color.Primary) + .clipShape(RoundedCorner(radius: 16, corners: [.topLeft, .topRight])) + } + } + } + .ignoresSafeArea(edges: .bottom) + } +} diff --git a/StockMate/StockMate/app/core/components/CategoryButton.swift b/StockMate/StockMate/app/core/components/CategoryButton.swift new file mode 100644 index 0000000..1e0b599 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CategoryButton.swift @@ -0,0 +1,32 @@ +// +// CategoryButton.swift +// StockMate +// +// Created by Admin on 11/8/25. +// + +import SwiftUI + +struct CategoryButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(isSelected ? .Primary : .black) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(isSelected ? Color(hex: "#E1E7F7") : Color.Light) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? Color.Primary : Color.GrayStroke, lineWidth: 1) + ) + } + } +} diff --git a/StockMate/StockMate/app/core/components/CustomButtonStyle.swift b/StockMate/StockMate/app/core/components/CustomButtonStyle.swift new file mode 100644 index 0000000..c25f344 --- /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)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .foregroundColor(.white) + .font(.system(size: fontSize, weight: fontWeight)) + .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) + ) + .foregroundColor(color) + .font(.system(size: fontSize, weight: fontWeight)) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.white) + ) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } + } +} diff --git a/StockMate/StockMate/app/core/components/CustomSecureField.swift b/StockMate/StockMate/app/core/components/CustomSecureField.swift new file mode 100644 index 0000000..c85f895 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CustomSecureField.swift @@ -0,0 +1,80 @@ +// +// CustomSecureField.swift +// StockMate +// +// Created by Admin on 10/9/25. +// + +import SwiftUI + +struct CustomSecureField: View { + var title: String + var placeholder: String + @Binding var text: String + var errorMessage: String? = nil + + @FocusState private var isFocused: Bool + @State private var showPassword = false + + // 테두리 색상 계산 로직 (CustomTextField와 동일) + private var borderColor: Color { + if let error = errorMessage, !error.isEmpty { + return .red + } else if isFocused { + return .Primary + } else { + return .LightBlue04 + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // 필드 제목 + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.black) + + // 텍스트 입력 박스 + ZStack { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(borderColor, lineWidth: 1) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white)) + // 포커스일 때만 그림자 표시 + .shadow( + color: isFocused + ? Color(hex: "#333333").opacity(0.1) + : Color.clear, + radius: 1, + x: 0, + y: 2 + ) + + HStack { + if showPassword { + TextField(placeholder, text: $text) + } else { + SecureField(placeholder, text: $text) + } + + Spacer() + + Button(action: { showPassword.toggle() }) { + Image(systemName: showPassword ? "eye.slash" : "eye") + .foregroundColor(.gray) + } + } + .focused($isFocused) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .frame(height: 46) + + Text(errorMessage ?? " ") + .font(.caption) + .foregroundColor(.red) + .frame(height: 14) // 고정 높이 확보 + } + } +} diff --git a/StockMate/StockMate/app/core/components/CustomTextField.swift b/StockMate/StockMate/app/core/components/CustomTextField.swift new file mode 100644 index 0000000..a2fa910 --- /dev/null +++ b/StockMate/StockMate/app/core/components/CustomTextField.swift @@ -0,0 +1,86 @@ +// +// CustomTextField.swift +// StockMate +// +// Created by Admin on 10/9/25. +// + +import SwiftUI + +struct CustomTextField: View { + var title: String + var placeholder: String + @Binding var text: String + var isEmail: Bool = false + var errorMessage: String? = nil + var isReadOnly: Bool = false + + @FocusState private var isFocused: Bool + + // 테두리 색상 계산 로직 + private var borderColor: Color { + if let error = errorMessage, !error.isEmpty { + return .red + } else if isFocused { + return .Primary + } else { + return .LightBlue04 + } + } + + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.black) + + ZStack { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(borderColor, lineWidth: 1) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.white) + ) + // 포커스일 때만 그림자 표시 + .shadow( + color: isFocused + ? Color(hex: "#333333").opacity(0.1) + : Color.clear, + radius: 1, + x: 0, + 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) // 높이 일정하게 고정 + + Text(errorMessage ?? " ") + .font(.caption) + .foregroundColor(.red) + .frame(height: 14) // 고정 높이 확보 + .opacity(errorMessage == nil ? 0 : 1) // 없을 땐 투명 + } + } +} diff --git a/StockMate/StockMate/app/core/components/DonutChartView.swift b/StockMate/StockMate/app/core/components/DonutChartView.swift new file mode 100644 index 0000000..5163845 --- /dev/null +++ b/StockMate/StockMate/app/core/components/DonutChartView.swift @@ -0,0 +1,108 @@ +// +// 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 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) { + // 도넛 차트 + 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.49), + angularInset: 1.9 + ) + .foregroundStyle( + LinearGradient( + gradient: Gradient(colors: [ + colors[index % colors.count], + colors[index % colors.count].opacity(0.5) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(8.0) + // 도넛 안쪽에 비율 표시 + .annotation(position: .overlay) { + let percentage = percentages[index] + Text("\(percentage, specifier: "%.1f")%") + .font(.system(size: 10, weight: .light)) + .foregroundColor(.black) + .offset(y: -2) + } + } + } + .frame(height: 150) + .chartLegend(.hidden) // 기본 범례 숨김 + } + + // 오른쪽 커스텀 범례 + VStack(alignment: .leading, spacing: 18) { + ForEach(Array(data.enumerated()), id: \.offset) { index, item in + let percentage = percentages[index] + HStack(spacing: 7) { + Circle() + .fill(colors[index % colors.count]) + .frame(width: 10, height: 10) + Text(item.categoryName) + .font(.system(size: 12, weight: .light)) + .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/core/components/InventoryCardView.swift b/StockMate/StockMate/app/core/components/InventoryCardView.swift new file mode 100644 index 0000000..017f669 --- /dev/null +++ b/StockMate/StockMate/app/core/components/InventoryCardView.swift @@ -0,0 +1,91 @@ +// +// InventoryCardView.swift +// StockMate +// +// Created by Admin on 10/16/25. +// + +import SwiftUI + + +struct InventoryCardView: View { + let item: InventoryItem + var showStatus: Bool = true // 필요 시 상태 표시 숨기기 옵션 + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + // 상단 카테고리 + Text(item.categoryName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + + Divider() + .frame(height: 0.2) + .background(Color.textGray2) + + HStack(alignment: .center, spacing: 12) { + // 제품 이미지 + AsyncImage(url: URL(string: item.image)) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + // 제품명, 트림/모델 + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.korName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + .lineLimit(2) + Spacer() + } + .padding(.top, 2) + + Text("\(item.trim) / \(item.model)") + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.gray) + } + .frame(height: 60, alignment: .top) + + // 상태 표시 (옵션) + if showStatus { + VStack(alignment: .center, spacing: 6) { + if item.isLack { + Text("수량 부족") + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.DangerBg) + .foregroundColor(.Danger) + .cornerRadius(12) + } else { + Text("수량 여유") + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.StatusGreenBg) + .foregroundColor(.StatusGreen) + .cornerRadius(12) + } + + VStack(alignment: .leading) { + Text("현재수량: \(item.amount)개") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.textGray1) + Text("최소수량: \(item.limitAmount)개") + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.textGray1) + } + } + } + } + } + .padding() + .background(Color.white) + .cornerRadius(10) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + } +} diff --git a/StockMate/StockMate/app/core/components/OrderRequestCardView.swift b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift new file mode 100644 index 0000000..b4c31f8 --- /dev/null +++ b/StockMate/StockMate/app/core/components/OrderRequestCardView.swift @@ -0,0 +1,129 @@ +// +// OrderRequestCardView.swift +// StockMate +// +// Created by Admin on 10/20/25. +// + +import SwiftUI + +struct OrderRequestCardView: View { + let item: InventoryItem + let quantity: Int + let onIncrease: () -> Void + let onDecrease: () -> Void + let onAddToCart: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.categoryName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + + Divider().frame(height: 0.2).background(Color.textGray2) + + HStack(alignment: .center, spacing: 12) { + // 부품 이미지 + AsyncImage(url: URL(string: item.image)) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + // 이름 및 정보 + VStack(alignment: .leading, spacing: 6) { + Text(item.korName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + .lineLimit(2) + + Text("\(item.trim) / \(item.model)") + .font(.system(size: 13)) + .foregroundColor(.gray) + + Text("\(item.price)원") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.black) + } + + Spacer() + + // 수량 컨트롤러 + if quantity == 0 { + Button(action: onAddToCart) { + Image("add_shopping_cart") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .padding(10) + .background(Color.white) + .clipShape(Circle()) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 4) + } + } else { + QuantityControlView( + quantity: quantity, + onIncrease: onIncrease, + onDecrease: onDecrease + ) + } + } + } + .padding() + .background(Color.white) + .cornerRadius(14) + .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: {} + ) + + // 수량 1 (카트에 하나 있음) + OrderRequestCardView( + item: sampleItem, + quantity: 1, + onIncrease: {}, + onDecrease: {}, + onAddToCart: {} + ) + + // 수량 3 (여러 개 담긴 상태) + OrderRequestCardView( + item: sampleItem, + quantity: 3, + onIncrease: {}, + onDecrease: {}, + onAddToCart: {} + ) + } + .padding() + .background(Color(uiColor: .systemGray6)) +} 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/core/components/QuantityControlView.swift b/StockMate/StockMate/app/core/components/QuantityControlView.swift new file mode 100644 index 0000000..7f455ab --- /dev/null +++ b/StockMate/StockMate/app/core/components/QuantityControlView.swift @@ -0,0 +1,60 @@ +// +// QuantityControlView.swift +// StockMate +// +// Created by Admin on 11/8/25. +// + +import SwiftUI + +struct QuantityControlView: View { + let quantity: Int + let onIncrease: () -> Void + let onDecrease: () -> Void + + var body: some View { + HStack(spacing: 10) { + // 감소 버튼 + Button(action: onDecrease) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .regular)) + .frame(width: 13, height: 13) + .foregroundColor(.black) + } + + // 수량 표시 + Text("\(quantity)") + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) + .animation(.easeInOut(duration: 0.2), value: quantity) + + // 증가 버튼 + Button(action: onIncrease) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .regular)) + .frame(width: 13, height: 13) + .foregroundColor(.black) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.white) + .cornerRadius(10) + .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) + } +} + +#Preview { + QuantityControlView( + quantity: 2, + onIncrease: { print("➕ 수량 증가") }, + onDecrease: { print("➖ 수량 감소") } + ) + .padding() + .background(Color.Light) +} diff --git a/StockMate/StockMate/app/core/components/RoundedCorner.swift b/StockMate/StockMate/app/core/components/RoundedCorner.swift new file mode 100644 index 0000000..43057fe --- /dev/null +++ b/StockMate/StockMate/app/core/components/RoundedCorner.swift @@ -0,0 +1,24 @@ +// +// RoundedCorner.swift +// StockMate +// +// Created by Admin on 10/26/25. +// + +import SwiftUI + +// 특정 모서리만 둥글게 처리할 수 있는 커스텀 Shape 구조체 +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity // 둥근 모서리의 반경 (기본값은 무한대) + var corners: UIRectCorner = .allCorners // 둥글게 적용할 모서리 (기본값: 전체 모서리) + + // 지정된 모서리에만 둥근 경로를 적용하여 Path 생성 + func path(in rect: CGRect) -> Path { + let path = UIBezierPath( + roundedRect: rect, + byRoundingCorners: corners, + cornerRadii: CGSize(width: radius, height: radius) + ) + return Path(path.cgPath) + } +} diff --git a/StockMate/StockMate/app/core/components/ToastModifier.swift b/StockMate/StockMate/app/core/components/ToastModifier.swift new file mode 100644 index 0000000..4fc4ab1 --- /dev/null +++ b/StockMate/StockMate/app/core/components/ToastModifier.swift @@ -0,0 +1,97 @@ +// +// ToastModifier.swift +// StockMate +// +// Created by Admin on 11/9/25. +// + +import SwiftUI + +struct ToastView: View { + let message: String + let iconName: String? // 아이콘 이름 (예: "checkmark.circle.fill" or "xmark.circle.fill") + let iconColor: Color? // 아이콘 색상 (예: .green, .red 등) + + var body: some View { + ZStack { + // 메시지 중앙 정렬 + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 40) // 아이콘 영역 고려해서 여백 확보 + + // 아이콘 왼쪽 고정 + if let iconName, let iconColor { + HStack { + Image(systemName: iconName) + .foregroundColor(iconColor) + .font(.system(size: 18)) + Spacer() + } + .padding(.leading, 16) + } + } + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.75)) + .cornerRadius(9999) + .padding(.horizontal, 24) + } +} + +// ViewModifier를 사용해 기존 View 위에 토스트를 표시 +struct ToastModifier: ViewModifier { + @Binding var isPresented: Bool + let message: String + let iconName: String? + let iconColor: Color? + let duration: Double + + func body(content: Content) -> some View { + ZStack { + content + + if isPresented { + VStack { + Spacer() + ToastView(message: message, iconName: iconName, iconColor: iconColor) + .transition(.opacity.combined(with: .scale)) + .padding(.bottom, 60) + } + .animation(.easeInOut(duration: 0.35), value: isPresented) + } + } + .onChange(of: isPresented) { shown in + if shown { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + withAnimation(.easeOut(duration: 0.4)) { + isPresented = false + } + } + } + } + } +} + +// View 확장을 통해 modifier를 간편하게 사용할 수 있도록 함 +extension View { + func toast( + isPresented: Binding, + message: String, + iconName: String? = nil, + iconColor: Color? = nil, + duration: Double = 2.0 + ) -> some View { + self.modifier( + ToastModifier( + isPresented: isPresented, + message: message, + iconName: iconName, + iconColor: iconColor, + duration: duration + ) + ) + } +} diff --git a/StockMate/StockMate/app/core/components/TopToast.swift b/StockMate/StockMate/app/core/components/TopToast.swift new file mode 100644 index 0000000..abf4a60 --- /dev/null +++ b/StockMate/StockMate/app/core/components/TopToast.swift @@ -0,0 +1,60 @@ +// +// TopToast.swift +// StockMate +// +// Created by Admin on 10/10/25. +// + +import SwiftUI + +struct TopToast: View { + let message: String + @Binding var isVisible: Bool + var iconName: String = "exclamationmark.triangle.fill" // 기본 아이콘 + var iconColor: Color = .Danger + var duration: Double = 2.2 + + var body: some View { + VStack(spacing: 0) { + if isVisible { + ZStack { + // 메시지 중앙 정렬 + Text(message) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color(hex: "AB3029")) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 40) // 아이콘 여백 확보 + + // 왼쪽 아이콘 + HStack { + Image(systemName: iconName) + .foregroundColor(iconColor) + .font(.system(size: 18)) + Spacer() + } + .padding(.leading, 16) + } + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background(Color.DangerBg.opacity(0.85)) + .cornerRadius(12) + .padding(.horizontal, 24) + .padding(.top, 14) + .transition(.move(edge: .top).combined(with: .opacity)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + withAnimation { + isVisible = false + } + } + } + } + + Spacer() + } + .frame(maxWidth: .infinity) + .animation(.easeInOut(duration: 0.3), value: isVisible) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + } +} diff --git a/StockMate/StockMate/app/core/components/UIApplication+Extension.swift b/StockMate/StockMate/app/core/components/UIApplication+Extension.swift new file mode 100644 index 0000000..f239309 --- /dev/null +++ b/StockMate/StockMate/app/core/components/UIApplication+Extension.swift @@ -0,0 +1,14 @@ +// +// UIApplication+Extension.swift +// StockMate +// +// Created by Admin on 11/9/25. +// + +import SwiftUI + +extension UIApplication { + func hideKeyboard() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/StockMate/StockMate/app/core/network/ApiClient.swift b/StockMate/StockMate/app/core/network/ApiClient.swift new file mode 100644 index 0000000..d28bd7a --- /dev/null +++ b/StockMate/StockMate/app/core/network/ApiClient.swift @@ -0,0 +1,27 @@ +// +// ApiClient.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +struct ApiClient { + static let baseURL = "https://api.stockmate.site/" // 기본 API 서버 주소 + static let shared: Session = { // Alamofire의 Session을 싱글톤 형태로 공유 + + // 요청 시 토큰 등을 자동으로 처리하기 위한 인터셉터 설정 + let interceptor = AuthInterceptor() + + // 네트워크 요청 관련 기본 설정 구성 + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 20 + config.timeoutIntervalForResource = 20 + + // 커스텀 설정과 인터셉터를 적용한 세션 생성 + return Session(configuration: config, interceptor: interceptor, eventMonitors: []) + }() +} + diff --git a/StockMate/StockMate/app/core/network/ApiResponse.swift b/StockMate/StockMate/app/core/network/ApiResponse.swift new file mode 100644 index 0000000..fbc4ceb --- /dev/null +++ b/StockMate/StockMate/app/core/network/ApiResponse.swift @@ -0,0 +1,15 @@ +// +// ApiResponse.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation + +struct ApiResponse: Decodable { + let status: Int + let success: Bool + let message: String + let data: T? +} diff --git a/StockMate/StockMate/app/core/network/AuthInterceptor.swift b/StockMate/StockMate/app/core/network/AuthInterceptor.swift new file mode 100644 index 0000000..76709f9 --- /dev/null +++ b/StockMate/StockMate/app/core/network/AuthInterceptor.swift @@ -0,0 +1,24 @@ +// +// AuthInterceptor.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +// API 요청 시 Access Token을 자동으로 헤더에 추가하는 인터셉터 +final class AuthInterceptor: RequestInterceptor, @unchecked Sendable { + private let tokenStore = TokenStore.shared + + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + var req = urlRequest + if let token = tokenStore.getAccessToken() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + completion(.success(req)) + } + + // TODO: 401 Unauthorized 응답 시 Refresh Token을 사용해 토큰 재발급 로직 추가 +} diff --git a/StockMate/StockMate/app/feature/auth/data/.gitkeep b/StockMate/StockMate/app/feature/auth/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/StockMate/StockMate/app/feature/auth/data/AuthApi.swift b/StockMate/StockMate/app/feature/auth/data/AuthApi.swift new file mode 100644 index 0000000..2528705 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/data/AuthApi.swift @@ -0,0 +1,65 @@ +// +// AuthApi.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +// === Request === +// 회원가입 요청 바디 +struct RegisterRequest: Encodable { + let email: String + let password: String + let owner: String + let address: String + let storeName: String + let businessNumber: String +} +// 로그인 요청 바디 +struct LoginRequest: Encodable { + let email: String + let password: String +} + + +// === Response === +// 회원가입 응답 +struct RegisterResponse: Decodable { + let status: Int + let success: Bool + let message: String +} + +// 로그인 응답 데이터 +struct LoginData: Decodable { + let accessToken: String + let refreshToken: String + let role: String +} + +// 로그인 응답 전체 구조 +struct LoginResponse: Decodable { + let status: Int + let success: Bool + let message: String + let data: LoginData? +} + +// === API === +// 인증 관련 API 모음 +enum AuthApi { + // POST - 회원가입 요청 + static func register(_ req: RegisterRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/auth/register" + return ApiClient.shared.request(url, method: .post, parameters: req, encoder: JSONParameterEncoder.default) + } + + // POST - 로그인 요청 + static func login(_ req: LoginRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/auth/login" + return ApiClient.shared.request(url, method: .post, parameters: req, encoder: JSONParameterEncoder.default) + } +} diff --git a/StockMate/StockMate/app/feature/auth/data/AuthRepositoryImpl.swift b/StockMate/StockMate/app/feature/auth/data/AuthRepositoryImpl.swift new file mode 100644 index 0000000..1e83f57 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/data/AuthRepositoryImpl.swift @@ -0,0 +1,23 @@ +// +// AuthRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +final class AuthRepositoryImpl: AuthRepositoryProtocol { + // 회원가입 요청 + func register(_ req: RegisterRequest) async -> AppResult> { + let dataReq = AuthApi.register(req) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + // 로그인 요청 + func login(_ req: LoginRequest) async -> AppResult> { + let dataReq = AuthApi.login(req) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/auth/data/KeychainHelper.swift b/StockMate/StockMate/app/feature/auth/data/KeychainHelper.swift new file mode 100644 index 0000000..7f972fb --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/data/KeychainHelper.swift @@ -0,0 +1,63 @@ +// +// KeychainHelper.swift +// StockMate +// +// Created by Admin on 10/7/25. +// +import Foundation +import Security + +final class KeychainHelper { + + static let standard = KeychainHelper() + private init() {} + + // MARK: - Save + func save(_ value: String, service: String, account: String) { + guard let data = value.data(using: .utf8) else { return } + + // 기존 값이 있으면 먼저 삭제 + delete(service: service, account: account) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data + ] + + SecItemAdd(query as CFDictionary, nil) + } + + // MARK: - Read + func read(service: String, account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) + + guard status == errSecSuccess, + let data = dataTypeRef as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } + + // MARK: - Delete + func delete(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} + diff --git a/StockMate/StockMate/app/feature/auth/data/TokenStore.swift b/StockMate/StockMate/app/feature/auth/data/TokenStore.swift new file mode 100644 index 0000000..45d7e42 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/data/TokenStore.swift @@ -0,0 +1,46 @@ +// +// TokenStore.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Security + +final class TokenStore: @unchecked Sendable { + static let shared = TokenStore() + private init() {} + + private let defaults = UserDefaults.standard + + // 토큰 및 역할 저장 + func save(access: String, refresh: String, role: String) { + defaults.set(role, forKey: "role") + KeychainHelper.standard.save(access, service: "com.stockmate", account: "accessToken") + KeychainHelper.standard.save(refresh, service: "com.stockmate", account: "refreshToken") + } + + // 저장된 토큰 및 역할 제거 + func clear() { + defaults.removeObject(forKey: "role") + KeychainHelper.standard.delete(service: "com.stockmate", account: "accessToken") + KeychainHelper.standard.delete(service: "com.stockmate", account: "refreshToken") + } + + // 액세스 토큰 조회 + func getAccessToken() -> String? { + return KeychainHelper.standard.read(service: "com.stockmate", account: "accessToken") + } + + // 리프레시 토큰 조회 + func getRefreshToken() -> String? { + return KeychainHelper.standard.read(service: "com.stockmate", account: "refreshToken") + } + + // 사용자 역할(Role) 조회 + func getRole() -> String? { + return defaults.string(forKey: "role") + } +} + diff --git a/StockMate/StockMate/app/feature/auth/domain/.gitkeep b/StockMate/StockMate/app/feature/auth/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/StockMate/StockMate/app/feature/auth/domain/AuthRepositoryProtocol.swift b/StockMate/StockMate/app/feature/auth/domain/AuthRepositoryProtocol.swift new file mode 100644 index 0000000..eac76f4 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/domain/AuthRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// AuthRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import Foundation +import Alamofire + +protocol AuthRepositoryProtocol { + // 회원가입 요청 + func register(_ req: RegisterRequest) async -> AppResult> + + // 로그인 요청 + func login(_ req: LoginRequest) async -> AppResult> +} diff --git a/StockMate/StockMate/app/feature/auth/ui/.gitkeep b/StockMate/StockMate/app/feature/auth/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/StockMate/StockMate/app/feature/auth/ui/HomeView.swift b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift new file mode 100644 index 0000000..3d6de8d --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/ui/HomeView.swift @@ -0,0 +1,268 @@ +// +// HomeView.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import SwiftUI + + +struct HomeView: View { + @EnvironmentObject var authViewModel: AuthViewModel + @StateObject private var userViewModel = UserViewModel() + @StateObject private var inventoryViewModel = InventoryViewModel() + @StateObject private var dashboardViewModel = DashboardViewModel() + @StateObject private var notificationViewModel = NotificationViewModel() + + @State private var selectedMonth: String? = nil + + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 15) { + // 상단 프로필 + HStack(spacing: 16) { + ProfileCircleView(name: userViewModel.userInfo?.owner ?? "사용자", size: 50) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 2) { + Image("location") + .foregroundColor(.gray) + Text(userViewModel.userInfo?.storeName ?? "가게명 없음") + .foregroundColor(.gray) + .font(.subheadline) + } + Text(userViewModel.userInfo?.owner ?? "이름 없음") + .font(.title3.bold()) + .foregroundColor(Color(hex: "#2B3A1A")) + } + + Spacer() + + NavigationLink(destination: NotificationListView()) { + ZStack(alignment: .topTrailing) { + Image("notification") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .foregroundColor(.gray) + + if notificationViewModel.unreadCount > 0 { + Text("\(notificationViewModel.unreadCount)") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(5) + .background(Color.red) + .clipShape(Circle()) + .offset(x: 2, y: -9) + } + } + } + .padding(.trailing, 5) + + + } + .padding(.horizontal) + + // 검색창 + NavigationLink(destination: InventorySearchView()) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + Text("부품을 검색하세요.") + .foregroundColor(.gray) + Spacer() + } + .padding() + .background(Color(.white)) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.GrayMordern400, lineWidth: 1) + ) + .padding(.horizontal) + } + .buttonStyle(.plain) + + lackStockSection + + // 도넛 차트 섹션 + VStack(alignment: .leading, spacing: 18) { + Text("지난달 카테고리 별 지출") + .font(.system(size: 15, weight: .semibold)) + .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + if dashboardViewModel.isLoading { + ProgressView("불러오는 중...") + .frame(height: 155) + } else if dashboardViewModel.categorySpendings.isEmpty { + Text("지난달 지출 내역이 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + .frame(height: 155, alignment: .center) + } else { + DonutChartView(data: dashboardViewModel.categorySpendings) + .frame(height: 155) + .background(Color.white) + .cornerRadius(16) + } + + Spacer() + } + } + .padding() + .background(Color.white) + .cornerRadius(16) + .padding(.horizontal) + + + // 막대그래프 섹션 + VStack(alignment: .leading, spacing: 8) { + Text("월간 지출 현황") + .font(.system(size: 15, weight: .semibold)) + .padding(4) + .frame(maxWidth: .infinity, alignment: .leading) + + if dashboardViewModel.isLoading { + ProgressView("데이터 불러오는 중...") + .frame(height: 163) + } else if dashboardViewModel.monthlySpendings.isEmpty { + Text("최근 지출 내역이 없습니다.") + .foregroundColor(.gray) + .frame(height: 163) + } else { + BarChartView( + values: dashboardViewModel.spendingRatios, + labels: dashboardViewModel.monthLabels, + amounts: dashboardViewModel.monthlySpendings.map { $0.totalAmount }, selectedMonth: $selectedMonth + ) + } + } + .padding() + .background(Color.white) + .cornerRadius(16) + .padding(.horizontal) + } + .padding(.vertical,5) + } + .background(Color.Light) + .task { + // 카테고리 데이터 로드 + await inventoryViewModel.loadLackCountByCategory() + await dashboardViewModel.fetchMonthlySpending() + await dashboardViewModel.fetchCategorySpending() + await notificationViewModel.fetchUnreadCount() + } + .onAppear { + Task { await userViewModel.loadUserInfo() } + } + // 화면 디자인 시 잠시 주석처리 + // 세션 만료 시 자동으로 로그인 뷰로 이동 + .onChange(of: userViewModel.shouldGoToLogin) { shouldGo in + if shouldGo { + print("세션 만료됨 → 로그인 화면으로 이동") + authViewModel.logout() + } + } + } + + private var lackStockSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("재고 부족 조회") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 13) { + if inventoryViewModel.lackCounts.isEmpty { + // 데이터가 없을 때도 공간 확보 + ForEach(0..<5) { _ in + StatusItem( + 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) + } + .frame(maxWidth: .infinity, minHeight: 70) + .padding() + .background(Color.white) + .cornerRadius(16) + .padding(.horizontal) + } +} + + +private func colorForCategory(_ category: String) -> Color { + switch category { + case "전기/램프": return .Hstatus1Bg + case "엔진/미션": return .Hstatus2Bg + case "하체/바디": return .Hstatus3Bg + case "내장/외장": return .Hstatus4Bg + case "기타소모품": return .Hstatus5Bg + default: return .gray.opacity(0.3) + } +} + + +private func iconForCategory(_ name: String) -> String { + switch name { + case "전기/램프": return "lightbulb" + case "엔진/미션": return "cog" + case "하체/바디": return "spanner" + case "내장/외장": return "chair" + case "기타소모품": return "package" + default: return "uploadprogress" + } +} + +// MARK: - 상태 아이템 컴포넌트 +struct StatusItem: View { + let title: String + let count: Int + let color: Color + let icon: String + + var body: some View { + VStack(spacing: 3) { + Image(icon) + .resizable() + .scaledToFit() + .frame(width: 25, height: 25) + .padding(12) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 100)) + + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.black) + .padding(.top, 5) + .lineLimit(1) + + Text("\(count)건") + .font(.caption) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.textGray1) + }.frame(maxWidth: .infinity, minHeight: 70) + } +} diff --git a/StockMate/StockMate/app/feature/auth/ui/LoginView.swift b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift new file mode 100644 index 0000000..a83eb77 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/ui/LoginView.swift @@ -0,0 +1,168 @@ +// +// LoginView.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + + +import SwiftUI + +struct LoginView: View { + @EnvironmentObject var authViewModel: AuthViewModel + + @State private var email = "" + @State private var password = "" + @State private var showPassword = false + + @State private var emailError: String? = nil + @State private var pwError: String? = nil + + // 토스트 관련 상태 추가 + @State private var showTopToast = false + @State private var topToastMessage = "" + + var onLogin: (String, String) -> Void = { _, _ in } + var onClickRegister: () -> Void = {} + + var body: some View { + ZStack { + + + VStack { + Spacer().frame(height: 140) + + // MARK: - Logo + Image("stockmate_logo") + .resizable() + .scaledToFit() + .frame(width: 216, height: 44) + + Spacer().frame(height: 67) + + // MARK: - Title + Text("로그인") + .font(.system(size: 28, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .foregroundColor(Color.DarkBlue01) + + Spacer().frame(height: 17) + + // MARK: - Text Fields + CustomTextField( + title: "이메일", + placeholder: "stockmate@gmail.com", + text: $authViewModel.email, + isEmail: true, + errorMessage: emailError + ) + .keyboardType(.emailAddress) + .padding(.horizontal, 24) + .onChange(of: authViewModel.email) { newValue in + // 입력 중 실시간 validation + emailError = isValidEmail(newValue) ? nil : "이메일 형식을 확인해주세요" + } + + Spacer().frame(height: 18) + + // 비밀번호 + CustomSecureField( + title: "비밀번호", + placeholder: "비밀번호를 입력하세요", + text: $authViewModel.password, + errorMessage: pwError + ) + .padding(.horizontal, 24) + .onChange(of: authViewModel.password) { newValue in + pwError = newValue.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" + } + + Spacer().frame(height: 26) + + // MARK: - Login Button + // 3) 버튼 — 시각적 상태 반영 + disabled 처리 + Button(action: { + // 버튼이 눌렸을 때는 한번 더 확정적으로 에러 상태를 설정 + // (뷰 업데이트 중이 아니므로 상태 변경해도 안전) + validateAndSetErrors() + guard isFormValid else { return } + + Task { + let success = await authViewModel.login() + if !success { + showToast("아이디 또는 비밀번호가 잘못되었습니다.") + } + // await authViewModel.login() + } + }) { + Text("로그인") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isFormValid ? Color.Primary : Color.gray.opacity(0.45)) + ) + .cornerRadius(4) + } + .padding(.horizontal, 24) + .disabled(!isFormValid) + + + Spacer().frame(height: 24) + + // 회원가입 링크 + HStack { + Text("계정이 없으신가요?") + .foregroundColor(Color.gray) + .font(.system(size: 13)) + Button(action: { onClickRegister() }) { + Text("회원가입") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(Color.Primary) + } + } + + Spacer() + + } + .background(Color.Light) + .onTapGesture { + UIApplication.shared.hideKeyboard() + } + .ignoresSafeArea() + + TopToast(message: topToastMessage, + isVisible: $showTopToast, + iconName: "exclamationmark.circle", + iconColor: .black) + .zIndex(1) // 다른 뷰 위로 + + + } + } + + // MARK: - 유효성 검사 함수 + // 1) 뷰 내부(바디 바깥) — 부작용 없는 computed property + private var isFormValid: Bool { + return isValidEmail(authViewModel.email) && authViewModel.password.count >= 8 + } + + // 4) body 바깥에 유틸 함수 추가 — 뷰 업데이트 중 호출하지 말고 이벤트에서만 호출 + private func validateAndSetErrors() { + // 이 함수는 호출되는 시점이 사용자 액션(버튼)일 때만 사용 + emailError = isValidEmail(authViewModel.email) ? nil : "이메일 형식을 확인해주세요" + pwError = authViewModel.password.count >= 8 ? nil : "8자 이상 비밀번호를 입력해주세요" + } + + // 상단 토스트 표시 함수 + private func showToast(_ message: String) { + topToastMessage = message + withAnimation { + showTopToast = true + } + } + +} diff --git a/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift b/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift new file mode 100644 index 0000000..bd9a1b4 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/ui/RegisterView.swift @@ -0,0 +1,281 @@ +// +// RegisterView.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import SwiftUI + +struct RegisterView: View { + @EnvironmentObject private var viewModel: AuthViewModel + // MARK: - 사용자 입력값 + @State private var email = "" + @State private var password = "" + @State private var confirmPassword = "" + @State private var owner = "" + @State private var storeName = "" + @State private var address = "" + @State private var bizNo = "" + + // MARK: - 에러 메시지 상태값 + @State private var emailError: String? = nil + @State private var pwError: String? = nil + @State private var confirmPasswordError: String? = nil + @State private var ownerError: String? = nil + @State private var storeNameError: String? = nil + @State private var addressError: String? = nil + @State private var bizNoError: String? = nil + + // MARK: - UI 상태 관리 + @State private var isLoading = false + @State private var showToast = false + @State private var showAddressSearch = false + @State private var showSuccessToast = false + + var body: some View { + ZStack { + // MARK: - 메인 스크롤 영역 + ScrollView { + VStack(spacing: 16) { + Spacer().frame(height: 70) + + // MARK: - 로고 + Image("stockmate_logo") + .resizable() + .scaledToFit() + .frame(width: 216, height: 44) + + Spacer().frame(height: 4) + + // MARK: - 화면 제목 + Text("회원가입") + .font(.system(size: 28, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .foregroundColor(Color.DarkBlue01) + + // MARK: - 입력 폼 + VStack { + // 이메일 + CustomTextField( + title: "이메일", + placeholder: "stockmate@gmail.com", + text: $email, + errorMessage: emailError + ) + .keyboardType(.emailAddress) + .onChange(of: email) { newValue in + emailError = isValidEmail(newValue) ? nil : "이메일 형식을 확인해주세요" + } + + // 비밀번호 + CustomSecureField( + title: "비밀번호", + placeholder: "비밀번호를 입력하세요", + text: $password, + errorMessage: pwError + ) + .onChange(of: password) { newValue in + pwError = isValidPassword(newValue) ? nil : "8자 이상, 영문+숫자 조합입니다." + // confirm도 재검증 + confirmPasswordError = (confirmPassword.isEmpty || confirmPassword == newValue) ? nil : "비밀번호가 일치하지 않습니다" + } + + // 비밀번호 확인 + CustomSecureField( + title: "비밀번호 확인", + placeholder: "비밀번호를 다시 입력하세요", + text: $confirmPassword, + errorMessage: confirmPasswordError + ) + .onChange(of: confirmPassword) { newValue in + confirmPasswordError = (password == newValue) ? nil : "비밀번호가 일치하지 않습니다" + } + // 대표자 이름 + CustomTextField( + title: "대표자 이름", + placeholder: "홍길동", + text: $owner, + errorMessage: nil + ) + // 지점 이름 + CustomTextField( + title: "지점 이름", + placeholder: "강남점", + text: $storeName, + errorMessage: nil + ) + // 주소 입력 필드 + VStack(alignment: .leading, spacing: 4) { + CustomTextField( + title: "주소", + placeholder: "도로명 주소를 검색하세요", + text: $address, + errorMessage: addressError, + isReadOnly: true + ) + .disabled(true) // 사용자가 직접 입력 못하게 + .onTapGesture { + // 탭해도 검색창 열 수 있게 (선택사항) + showAddressSearch.toggle() + } + .sheet(isPresented: $showAddressSearch) { + KakaoZipCodeView(address: $address) + } + } + + CustomTextField( + title: "사업자등록번호", + placeholder: "000-00-00000", + text: $bizNo, + errorMessage: bizNoError + ) + .keyboardType(.numberPad) + .onChange(of: bizNo) { newValue in + formatBizNoInput(newValue) + bizNoError = isValidBizNo(newValue) ? nil : "형식: 000-00-00000" + } + } + .padding(.horizontal, 24) + + + // MARK: - 회원가입 버튼 + if isLoading { + ProgressView("회원가입 중...") + .progressViewStyle(CircularProgressViewStyle()) + } else { + Button(action: { + validateAndSetErrors() + guard isFormValid else { return } + Task { + isLoading = true + let success = await viewModel.register( + email: email, + password: password, + owner: owner, + address: address, + storeName: storeName, + bizNo: bizNo.filter { $0.isNumber } + ) + isLoading = false + } + }) { + Text("회원가입") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isFormValid ? Color.Primary : Color.gray.opacity(0.45)) + ) + .cornerRadius(8) + } + .disabled(!isFormValid) + .padding(.horizontal, 24) + } + + // 상단 토스트 (오류/알림용) + TopToast(message: viewModel.message, isVisible: $showToast) + + Spacer().frame(height: 5) + + // MARK: - 로그인 페이지로 이동 링크 + HStack(spacing: 4) { + Text("이미 계정이 있으신가요?") + .foregroundColor(Color.gray) + .font(.system(size: 13)) + Button(action: { + viewModel.goToLogin() + print("로그인으로 이동") + }) { + Text("로그인") + .fontWeight(.semibold) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(Color.Primary) + } + } + .padding(.bottom, 40) + + // 키보드 가림 방지용 여백 + Spacer().frame(height: 300) + } + } + .background(Color.Light) + .ignoresSafeArea() + .onTapGesture { + UIApplication.shared.hideKeyboard() + } + .scrollDismissesKeyboard(.interactively) // 손가락으로 스크롤하면 키보드 자동 내려감 + .onChange(of: viewModel.message) { newMsg in + guard !newMsg.isEmpty else { return } + showToast = true + } + + // 회원가입 성공 토스트 + BottomToast( + message: "회원가입 성공", + isVisible: $showSuccessToast, + iconName: "toastlogo", + backgroundColor: Color(hex: "EEEDF5") // 초록색 + ) + .zIndex(1) // 다른 뷰 위로 + } + + } + + // MARK: - 전체 폼 유효성 검사 + private var isFormValid: Bool { + return isValidEmail(email) + && isValidPassword(password) + && password == confirmPassword + && !owner.trimmingCharacters(in: .whitespaces).isEmpty + && !storeName.trimmingCharacters(in: .whitespaces).isEmpty + && !address.trimmingCharacters(in: .whitespaces).isEmpty + && isValidBizNo(bizNo) + } + + // MARK: - 입력값 검증 및 에러 설정 + private func validateAndSetErrors() { + emailError = isValidEmail(email) ? nil : "이메일 형식을 확인해주세요" + pwError = isValidPassword(password) ? nil : "8자 이상, 영문+숫자 조합입니다." + confirmPasswordError = (password == confirmPassword) ? nil : "비밀번호가 일치하지 않습니다" + addressError = address.isEmpty ? "주소를 입력해주세요." : nil + bizNoError = isValidBizNo(bizNo) ? nil : "형식: 000-00-00000" + } + + // MARK: - 사업자등록번호 자동 포맷팅 + private func formatBizNoInput(_ input: String) { + // 숫자만 남기기 + let digitsOnly = input.filter { $0.isNumber } + + // 하이픈 자동 삽입 + 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)) // 하이픈 포함 + } + + // 상태 업데이트 + if formatted != bizNo { + bizNo = formatted + } + } +} + diff --git a/StockMate/StockMate/app/feature/auth/viewmodel/.gitkeep b/StockMate/StockMate/app/feature/auth/viewmodel/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/StockMate/StockMate/app/feature/auth/viewmodel/AuthViewModel.swift b/StockMate/StockMate/app/feature/auth/viewmodel/AuthViewModel.swift new file mode 100644 index 0000000..72eea38 --- /dev/null +++ b/StockMate/StockMate/app/feature/auth/viewmodel/AuthViewModel.swift @@ -0,0 +1,111 @@ +// +// AuthViewModel.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import SwiftUI + +// 인증 상태를 나타내는 enum +enum AuthState { + case unauthenticated + case registering + case authenticated +} + +// 인증 관련 로직(ViewModel) +@MainActor +final class AuthViewModel: ObservableObject { + @Published var email = "" + @Published var password = "" + @Published var message: String = "" + @Published var authState: AuthState = .unauthenticated + + // MARK: - Dependencies + private let repo: AuthRepositoryProtocol + + // MARK: - Init + init(repo: AuthRepositoryProtocol = AuthRepositoryImpl()) { + self.repo = repo + } + + // MARK: - 로그인 + func login() async -> Bool{ + print("로그인 시도 - email: \(email), password: \(password)") + let req = LoginRequest(email: email, password: password) + let result = await repo.login(req) + print("로그인 응답 도착: \(result)") + + switch result { + case .success(let apiResp): + guard let data = apiResp.data else { + print("데이터 없음: \(apiResp.message)") + message = apiResp.message + return false + } + TokenStore.shared.save( + access: data.accessToken, + refresh: data.refreshToken, + role: data.role + ) + message = "로그인 성공" + authState = .authenticated + print("authState 변경됨 → authenticated") + return true + + case .failure(let err): + print("로그인 실패: \(err.message)") + message = err.message + return false + } + } + + // MARK: - 로그아웃 + func logout() { + TokenStore.shared.clear() + authState = .unauthenticated + } + + // MARK: - 화면 상태 전환 + func goToLogin() { + authState = .unauthenticated + } + + func goToRegister() { + authState = .registering + } + + // MARK: - 회원가입 + func register( + email: String, + password: String, + owner: String, + address: String, + storeName: String, + bizNo: String + ) async -> Bool { + + let req = RegisterRequest( + email: email, + password: password, + owner: owner, + address: address, + storeName: storeName, + businessNumber: bizNo + ) + + let result = await repo.register(req) + print("회원가입 응답 도착: \(result)") + switch result { + case .success(let apiResp): + message = apiResp.message + authState = .unauthenticated + return true + case .failure(let err): + message = err.message + return false + } + } + +} diff --git a/StockMate/StockMate/app/feature/cart/data/CartApi.swift b/StockMate/StockMate/app/feature/cart/data/CartApi.swift new file mode 100644 index 0000000..23281f4 --- /dev/null +++ b/StockMate/StockMate/app/feature/cart/data/CartApi.swift @@ -0,0 +1,82 @@ +// +// CartApi.swift +// StockMate +// +// Created by Admin on 10/25/25. +// + +import Foundation +import Alamofire + +// === Response === +// 빈 데이터 응답용 +struct VoidData: Codable {} + +// get/put/post 응답, put,post는 totalPrice가 없음 -> optional +struct CartData: Decodable { + let cartId: Int + let memberId: Int + let items: [CartItem] + let totalPrice: Int? +} + +// put,post는 cartItemId,partId,amount만 필요 -> optional +struct CartItem: Decodable, Identifiable { + let cartItemId: Int + let partId: Int + var amount: Int + let partName: String? + let categoryName: String? + let brand: String? + let model: String? + let trim: String? + let price: Int? + let stock: Int? + let image: String? + + var id: Int { partId } + +} + + +// === Request === put,post만 요청값이 있음 +struct CartUpdateRequest: Encodable { + let items: [CartUpdateItem] +} + +struct CartUpdateItem: Encodable { + let partId: Int + let amount: Int +} + +enum CartApi { + // GET - 장바구니 조회 + static func fetchCart() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/cart" + return ApiClient.shared.request(url, method: .get) + } + + // POST - 장바구니 등록 (추가) + static func addToCart(_ body: CartUpdateRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/cart" + return ApiClient.shared.request(url, + method: .post, + parameters: body, + encoder: JSONParameterEncoder.default) + } + + // PUT - 장바구니 수정 (전체 덮어쓰기 형태) + static func updateCart(_ body: CartUpdateRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/cart" + return ApiClient.shared.request(url, + method: .put, + parameters: body, + encoder: JSONParameterEncoder.default) + } + + // DELETE - 장바구니 전체 비우기 (파라미터 없음) + static func clearCart() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/cart" + return ApiClient.shared.request(url, method: .delete) + } +} diff --git a/StockMate/StockMate/app/feature/cart/data/CartRepositoryImpl.swift b/StockMate/StockMate/app/feature/cart/data/CartRepositoryImpl.swift new file mode 100644 index 0000000..e72b3b3 --- /dev/null +++ b/StockMate/StockMate/app/feature/cart/data/CartRepositoryImpl.swift @@ -0,0 +1,42 @@ +// +// CartRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/25/25. +// + + +import Foundation +import Alamofire + +// 장바구니 관련 API 통신을 수행하는 Repository 구현체 +final class CartRepositoryImpl: CartRepositoryProtocol { + + // MARK: - 장바구니 조회 + func fetchCart() async -> AppResult> { + let req = CartApi.fetchCart() + return await safeApi(req, decodeTo: ApiResponse.self) + } + + // MARK: - 장바구니 추가 + func addToCart( + request: CartUpdateRequest + ) async -> AppResult> { + let req = CartApi.addToCart(request) + return await safeApi(req, decodeTo: ApiResponse.self) + } + + // MARK: - 장바구니 수정 + func updateCart( + request: CartUpdateRequest + ) async -> AppResult> { + let req = CartApi.updateCart(request) + return await safeApi(req, decodeTo: ApiResponse.self) + } + + // MARK: - 장바구니 비우기 + func clearCart() async -> AppResult> { + let req = CartApi.clearCart() + return await safeApi(req, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/cart/domain/CartRepositoryProtocol.swift b/StockMate/StockMate/app/feature/cart/domain/CartRepositoryProtocol.swift new file mode 100644 index 0000000..82b44a0 --- /dev/null +++ b/StockMate/StockMate/app/feature/cart/domain/CartRepositoryProtocol.swift @@ -0,0 +1,24 @@ +// +// CartRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/25/25. +// + +import Foundation +import Alamofire + +protocol CartRepositoryProtocol { + func fetchCart() async -> AppResult> + + func addToCart( + request: CartUpdateRequest + ) async -> AppResult> + + func updateCart( + request: CartUpdateRequest + ) async -> AppResult> + + func clearCart() async -> AppResult> +} + diff --git a/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift b/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift new file mode 100644 index 0000000..3f21bfd --- /dev/null +++ b/StockMate/StockMate/app/feature/cart/ui/DeliveryStatusView.swift @@ -0,0 +1,106 @@ +// +// DeliveryStatusView.swift +// StockMate +// +// Created by Admin on 10/30/25. +// + +import SwiftUI + +struct DeliveryStep { + let title: String + let iconName: String // Asset 이름 +} + + +struct DeliveryStatusView: View { + let steps: [DeliveryStep] = [ + DeliveryStep(title: "결제완료", iconName: "check"), + DeliveryStep(title: "상품준비중", iconName: "uploadprogress"), + DeliveryStep(title: "배송시작", iconName: "flag"), + DeliveryStep(title: "배송중", iconName: "rocket"), + DeliveryStep(title: "배송완료", iconName: "pindrop") + ] + + let currentStep: Int + + var body: some View { + GeometryReader { geo in + HStack(alignment: .center, spacing: 4) { + ForEach(0.. Int { + return items.first(where: { $0.partId == partId })?.amount ?? 0 + } + + private func updateLocalCartState() { + let total = items.reduce(0) { result, item in + result + (item.price ?? 0) * item.amount + } + + if let cart = cart { + self.cart = CartData( + cartId: cart.cartId, + memberId: cart.memberId, + items: self.items, + totalPrice: total + ) + } else { + // fallback: cart가 nil일 수 있는 초기 로드 상황 대비 + self.cart = CartData( + cartId: -1, + memberId: -1, + items: self.items, + totalPrice: total + ) + } + + objectWillChange.send() // 중요! SwiftUI에게 “바뀌었어!” 알림 + } + + + func increaseQuantity(for partId: Int) async { + if let index = items.firstIndex(where: { $0.partId == partId }) { + items[index].amount += 1 + } else { + items.append(CartItem( + cartItemId: 0, + partId: partId, + amount: 1, + partName: nil, + categoryName: nil, + brand: nil, + model: nil, + trim: nil, + price: nil, + stock: nil, + image: nil + )) + } + + await syncCart() + updateLocalCartState() + } + + func decreaseQuantity(for partId: Int) async { + guard let index = items.firstIndex(where: { $0.partId == partId }) else { + return + } + + if items[index].amount > 1 { + items[index].amount -= 1 + } else { + items.remove(at: index) + } + + await syncCart() + updateLocalCartState() + } + + private func syncCart() async { + if items.isEmpty { + // 장바구니가 빈 경우는 clearCart 호출 + await clearCart() + return + } + let updates = items.map { + CartUpdateItem(partId: $0.partId, amount: $0.amount) + } + let request = CartUpdateRequest(items: updates) + + switch await repository.updateCart(request: request) { + case .success: + await fetchCart() + case .failure(let error): + handleError(error) + } + } + +} 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..e00051b --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryApi.swift @@ -0,0 +1,121 @@ +// +// HistoryApi.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +// === Response === +// 입출고 히스토리 페이지 데이터 +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 +} + + +// === 예치금 거래내역 === +// 예치금 거래내역 페이지 데이터 +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 { + let transactionId: Int + let transactionType: String // "CHARGE" or "PAY" + let transactionTime: String? + let totalAmount: Int + let orderId: Int? + let orderItems: [OrderItemHistory]? + let balance: Int +} + +// 거래내역에 포함된 주문 항목 +struct OrderItemHistory: Decodable { + let id: Int + let name: String + let image: String + let korName: String + let categoryName: String +} + + +// === API === +// 입출고 및 거래내역 관련 API 모음 +enum HistoryApi { + // GET - 가맹점별 입출고 히스토리 조회 + 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) + } + + // 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 new file mode 100644 index 0000000..ad9e84a --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/data/HistoryRepositoryImpl.swift @@ -0,0 +1,25 @@ +// +// HistoryRepositoryImpl.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +// === Repository Implementation === +// 입출고 및 예치금 거래내역 데이터 요청을 처리하는 구현체 +final class HistoryRepositoryImpl: HistoryRepositoryProtocol { + // GET - 입출고 히스토리 조회 + func getInOutHistory(page: Int, size: Int) async -> AppResult> { + let request = HistoryApi.getInOutHistory(page: page, size: size) + return await safeApi(request, decodeTo: ApiResponse.self) + } + + // GET - 예치금 거래내역 조회 + 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 new file mode 100644 index 0000000..5c534fb --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/domain/HistoryRepositoryProtocol.swift @@ -0,0 +1,20 @@ +// +// HistoryRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + +// === Repository Protocol === +// 입출고 및 예치금 거래내역 관련 데이터 요청 정의 +protocol HistoryRepositoryProtocol { + + // GET - 입출고 히스토리 조회 + func getInOutHistory(page: Int, size: Int) async -> AppResult> + + // GET - 예치금 거래내역 조회 + func getPaymentTransaction(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..2f6dc2a --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/InOutHistoryView.swift @@ -0,0 +1,184 @@ +// +// InOutHistoryView.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import SwiftUI + +struct InOutHistoryView: View { + @Environment(\.dismiss) private var dismiss + @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, 8) + + // 해당 날짜의 히스토리 카드들 + ForEach(histories) { history in + InOutHistoryCard(history: history) + } + } + } + } + .padding(.bottom) + } + .padding(.top) + } + } + .background(Color.Light) + .navigationTitle("입출고 히스토리") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .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: 60, height: 60) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(firstItem.korName) + .font(.system(size: 15)) + .lineLimit(1) + if history.items.count > 1 { + Text("외 \(history.items.count - 1)개") + .font(.caption) + .foregroundColor(.gray) + } + } + } + } + + Spacer() + + // 입출고 상태 + Text(statusText(history.status)) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(statusBgColor(history.status)) + .foregroundColor(statusTextColor(history.status)) + .cornerRadius(12) + + 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(9) + .background(Color.white) + .cornerRadius(16) + .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..edd2ede --- /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 { + @Environment(\.dismiss) private var dismiss + 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) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + } +} + +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.historyQuantity)개") + .font(.system(size: 12)) + .foregroundColor(.gray) + + Text("\(part.price * part.historyQuantity)원") + .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" + ] + + let trimmed = timestamp.trimmingCharacters(in: .whitespacesAndNewlines) + let parser = DateFormatter() + parser.locale = Locale(identifier: "en_US_POSIX") + parser.timeZone = TimeZone(identifier: "Asia/Seoul") // 서버 시간 기준으로 맞춤 + + var date: Date? = nil + for format in inputFormats { + parser.dateFormat = format + if let parsed = parser.date(from: trimmed) { + date = parsed + break + } + } + + guard let finalDate = date else { return timestamp } + + // 출력도 한국시간으로 + let output = DateFormatter() + output.locale = Locale(identifier: "ko_KR") + 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/dashboard/ui/TransactionTypeListView.swift b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift new file mode 100644 index 0000000..dac5ebd --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/ui/TransactionTypeListView.swift @@ -0,0 +1,160 @@ +// +// TransactionTypeListView.swift +// StockMate +// +// Created by Admin on 11/6/25. +// + +import SwiftUI + +struct TransactionTypeListView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = HistoryViewModel() + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(viewModel.transactions, id: \.transactionId) { item in + TransactionCard(item: item) + .onAppear { + Task { + await viewModel.loadMoreTransactionsIfNeeded(currentItem: item) + } + } + } + } + .padding(.horizontal) + .padding(.top, 10) + } + .background(Color.Light.ignoresSafeArea()) + .navigationTitle("예치금 히스토리") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .overlay { + if viewModel.isTransactionLoading && viewModel.transactions.isEmpty { + ProgressView("불러오는 중...") + } + } + .task { + if viewModel.transactions.isEmpty { + await viewModel.fetchPaymentTransactions() + } + } + } + .background(Color.Light) + } +} + +struct TransactionCard: View { + let item: PaymentTransactionItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + + // PAY일 때만 부품 이미지 표시 + if item.transactionType == "CHARGE" { + Image("exchange") + .foregroundColor(Color.Primary) + .frame(width: 64, height: 64) + .cornerRadius(10) + }else { + // 부품 대표 이미지 + 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) + } + + + VStack(alignment: .leading, spacing: 5) { + 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(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + + } else if item.transactionType == "CHARGE" { + Text("예치금 충전") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + } else { + HStack { + Text("주문 취소:") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.black) + if 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(.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(13) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 4) + } +} + 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..82fa036 --- /dev/null +++ b/StockMate/StockMate/app/feature/dashboard/viewmodel/HistoryViewModel.swift @@ -0,0 +1,117 @@ +// +// HistoryViewModel.swift +// StockMate +// +// Created by Admin on 11/1/25. +// + +import Foundation +import Alamofire + + +// === ViewModel === +// 입출고 및 예치금 거래내역 화면에서 사용할 데이터 상태 관리 +@MainActor +final class HistoryViewModel: ObservableObject { + + // MARK: - 입출고 히스토리 관련 상태 + @Published var histories: [HistoryItem] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @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 + + // Repository 의존성 주입 + 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) + 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 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) + } + } + + // === 예치금 거래내역 === + // 예치금 거래내역 조회 + 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) + } + } + + // 무한 스크롤 시 다음 페이지 로드 (예치금 내역) + 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.transactionId == item.transactionId }), + currentIndex >= thresholdIndex { + await fetchPaymentTransactions(page: transactionPage + 1) + } + } +} diff --git a/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift b/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift new file mode 100644 index 0000000..058b8f1 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/data/InventoryApi.swift @@ -0,0 +1,100 @@ +// +// InventoryApi.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import Foundation +import Alamofire + + +// MARK: - 재고 조회 응답 구조 +struct InventoryResponse: Decodable { + let status: Int + let success: Bool + let message: String + let data: InventoryPageData? +} + +// MARK: - 페이징 데이터 +struct InventoryPageData: Decodable { + let content: [InventoryItem] + let page: Int + let size: Int + let totalElements: Int + let totalPages: Int +} + +// MARK: - 개별 재고 아이템 +struct InventoryItem: Decodable, Identifiable { + let id: Int + let name: String + let price: Int + let image: String + let trim: String + let model: String + let category: Int + let korName: String + let engName: String + let categoryName: String + let stock: Int // 본사 재고 + let amount: Int // 지점별 재고 + let limitAmount: Int // 지점별 최소 수량 + let isLack: Bool +} + +// MARK: - 카테고리별 부족 재고 개수 +struct LackCountItem: Decodable, Identifiable { + var id: String { categoryName } // SwiftUI ForEach에서 식별자 사용 + let categoryName: String + let count: Int +} + + +// MARK: 재고 목록 조회 +enum InventoryApi { + static func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) -> DataRequest { + var url = ApiClient.baseURL + "api/v1/store/search?page=\(page)&size=\(size)" + + for c in categoryNames { + url += "&categoryName=\(c.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + for t in trims { + url += "&trim=\(t.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + for m in models { + url += "&model=\(m.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 부족 재고 목록 조회 + static func getUnderLimitList(categoryName: String? = nil, page: Int = 0, size: Int = 10) -> DataRequest { + var url = ApiClient.baseURL + "api/v1/store/under-limit?page=\(page)&size=\(size)" + if let categoryName = categoryName, !categoryName.isEmpty { + url += "&categoryName=\(categoryName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 부품명 검색 + static func findByName(name: String, page: Int, size: Int) -> DataRequest { + let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let url = ApiClient.baseURL + "api/v1/store/find-name?name=\(encodedName)&page=\(page)&size=\(size)" + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 카테고리별 부족 재고 개수 조회 + static func getLackCountByCategory() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/store/lack-count" + return ApiClient.shared.request(url, method: .get) + } +} diff --git a/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift b/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift new file mode 100644 index 0000000..8bc5dac --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/data/InventoryRepositoryImpl.swift @@ -0,0 +1,57 @@ +// +// InventoryRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import Foundation +import Alamofire + +// MARK: - 재고 관련 Repository 구현체 +final class InventoryRepositoryImpl: InventoryRepositoryProtocol { + + // MARK: 재고 리스트 조회 + func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) async -> AppResult> { + let dataReq = InventoryApi.getInventoryList( + page: page, + size: size, + categoryNames: categoryNames, + trims: trims, + models: models + ) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + // MARK: 부족 재고 목록 조회 + func getUnderLimitList( + categoryName: String?, + page: Int, + size: Int + ) async -> AppResult> { + let dataReq = InventoryApi.getUnderLimitList(categoryName: categoryName, page: page, size: size) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + // MARK: 부품명 검색 + func findByName( + name: String, + page: Int, + size: Int + ) async -> AppResult> { + let dataReq = InventoryApi.findByName(name: name, page: page, size: size) + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } + + // MARK: 카테고리별 부족 재고 개수 조회 + func getLackCountByCategory() async -> AppResult> { + let dataReq = InventoryApi.getLackCountByCategory() + return await safeApi(dataReq, decodeTo: ApiResponse<[LackCountItem]>.self) + } +} diff --git a/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift b/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift new file mode 100644 index 0000000..19873d5 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/domain/InventoryRepositoryProtocol.swift @@ -0,0 +1,39 @@ +// +// InventoryRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import Foundation +import Alamofire + +// MARK: - 재고 관련 Repository 프로토콜 +protocol InventoryRepositoryProtocol { + // MARK: 재고 리스트 조회 + func getInventoryList( + page: Int, + size: Int, + categoryNames: [String], + trims: [String], + models: [String] + ) async -> AppResult> + + // MARK: 부족 재고 목록 조회 + func getUnderLimitList( + categoryName: String?, + page: Int, + size: Int + ) async -> AppResult> + + // MARK: 부품명 검색 + func findByName( + name: String, + page: Int, + size: Int + ) async -> AppResult> + + // MARK: 카테고리별 부족 재고 개수 조회 + func getLackCountByCategory() async -> AppResult> + +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift b/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift new file mode 100644 index 0000000..7f23226 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/IncomingScanView.swift @@ -0,0 +1,105 @@ +// +// IncomingScanView.swift +// StockMate +// +// Created by Admin on 10/13/25. +// + +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 { + 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() + } + + // 로딩 표시 + if orderViewModel.isLoading { + Color.black.opacity(0.3).ignoresSafeArea() + ProgressView("입고 처리 중...") + .padding() + .background(.ultraThinMaterial) + .cornerRadius(10) + } + } + .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) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + } + + 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/InventorySearchView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventorySearchView.swift new file mode 100644 index 0000000..1fd2b69 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/InventorySearchView.swift @@ -0,0 +1,239 @@ +// +// InventorySearchView.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + + +import SwiftUI + +struct InventorySearchView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var inventoryViewModel = InventoryViewModel() + @State private var searchText = "" + + // 카테고리, 분류, 모델 + private let categories = ["전기/램프", "엔진/미션", "하체/바디", "내장/외장", "기타소모품"] + private let trims = ["준중형/소형", "중형", "대형", "SUV", "화물/트럭/승합", "수소/전기"] + private let trimToModels: [String: [String]] = [ + "준중형/소형": [ "아반떼MD", "아반떼AD", "아반떼CN7", "I30", "엑센트", "아이오닉", "벨로스터", "캐스퍼" ], + "중형": [ "NF소나타", "YF소나타", "LF소나타", "DN8소나타", "그랜저TG", "그랜저HG", "그랜저IG", "그랜저GN7", "I40" ], + "대형": ["제네시스BH", "에쿠스"], + "SUV": [ "베뉴", "코나OS", "코나SX2", "투싼IX", "투싼TL", "투싼NX4", "싼타페CM", "싼타페DM", "싼타페TM", "싼타페MX5", "맥스크루즈", "베라크루즈", "팰리세이드LX2", "팰리세이드LX3" ], + "화물/트럭/승합": [ "스타렉스", "그랜드스타렉스", "스타리아", "포터2", "쏠라티", "마이티", "메가트럭", "카운티" ], + "수소/전기": ["아이오닉5", "아이오닉6", "아이오닉9", "넥쏘FE", "넥쏘NH2"] + ] + + private var filteredModels: [String] { + if inventoryViewModel.selectedTrims.isEmpty { + return trimToModels.values.flatMap { $0 } + } else { + return inventoryViewModel.selectedTrims + .flatMap { trimToModels[$0] ?? [] } + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // 검색창 + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("부품을 검색하세요.", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .onSubmit { + let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + Task { + await inventoryViewModel.searchByName(name: term, reset: true) + } + } + + if !searchText.isEmpty { + Button(action: { + searchText = "" + inventoryViewModel.isSearching = false + }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + } + .buttonStyle(.plain) + .padding(.trailing,3) + } + } + .padding() + .background(Color(.white)) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + ) + .padding(.horizontal) + .padding(.vertical) + + // 필터 및 초기화 버튼 + HStack(spacing: 10) { + FilterMenu( + title: "카테고리", + items: categories, + selectedItems: inventoryViewModel.selectedCategories, + onTap: { inventoryViewModel.toggleCategory($0) } + ) + + FilterMenu( + title: "분류", + items: trims, + selectedItems: inventoryViewModel.selectedTrims, + onTap: { inventoryViewModel.toggleTrim($0) } + ) + + FilterMenu( + title: "모델", + items: filteredModels, + selectedItems: inventoryViewModel.selectedModels, + onTap: { inventoryViewModel.toggleModel($0) } + ) + + // 초기화 버튼 + Button(action: { + inventoryViewModel.resetFilters(with: searchText) + }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor( + inventoryViewModel.selectedCategories.isEmpty && + inventoryViewModel.selectedTrims.isEmpty && + inventoryViewModel.selectedModels.isEmpty + ? .black + : .Primary + ) + .padding(.trailing, 8) + .rotationEffect(.degrees(35)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + + } + .padding(.horizontal) + .padding(.bottom, 16) + + // 재고 리스트 + ScrollView { + LazyVStack(spacing: 10) { + + ForEach( + inventoryViewModel.isSearching + ? inventoryViewModel.filteredSearchResults // 검색 + 필터링 + : inventoryViewModel.inventoryItems + ) { item in + InventoryCardView(item: item) + .padding(.horizontal) + .onAppear { + Task { + if inventoryViewModel.isSearching { + if item.id == inventoryViewModel.filteredSearchResults.last?.id, + inventoryViewModel.searchHasMore { + await inventoryViewModel.loadMore(searchText: searchText) + } + } else { + if item.id == inventoryViewModel.inventoryItems.last?.id, + inventoryViewModel.hasMore { + await inventoryViewModel.loadMore(searchText: searchText) + } + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical) + } + } + } + } + .background(Color.Light) + .navigationTitle("재고 조회") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .onTapGesture { + UIApplication.shared.hideKeyboard() + } + .task { + await inventoryViewModel.loadInventoryList(reset: true) + } + } + } +} + +// 필터 버튼 공용 컴포넌트 +struct FilterMenu: View { + let title: String + let items: [String] + let selectedItems: [String] + let onTap: (String) -> Void + + var displayTitle: String { + if selectedItems.isEmpty { + return title + } else { + return "\(title) (\(selectedItems.count))" + } + } + + var isActive: Bool { !selectedItems.isEmpty } + + var body: some View { + Menu { + ForEach(items, id: \.self) { item in + Button { + onTap(item) + } label: { + HStack { + Text(item) + if selectedItems.contains(item) { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.Primary) + } + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(isActive ? .Primary : .gray) + + Text(displayTitle) + .font(.system(size: 13)) + .foregroundColor(isActive ? .Primary : .black) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .fixedSize(horizontal: true, vertical: false) + .background(isActive ? Color(hex: "DBEAFE") : Color.Light) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(8) + } + } +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift new file mode 100644 index 0000000..c6a598c --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/InventoryView.swift @@ -0,0 +1,185 @@ +// +// InventoryView.swift +// StockMate +// +// Created by Admin on 10/12/25. +// + +import SwiftUI +struct InventoryView: View { + @Binding var selectedTab: Int + @Binding var tabTappedTrigger: Bool + + @StateObject private var inventoryViewModel = InventoryViewModel() + @State private var showScrollToTopButton = false + @Namespace private var topID + + @State private var lastTabSelection = 0 + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + ZStack { + ScrollView { + VStack(spacing: 0) { + // 스크롤 감지용 (맨 위) + GeometryReader { geo in + Color.clear + .onChange(of: geo.frame(in: .global).minY) { newValue in + withAnimation(.easeInOut(duration: 0.25)) { + showScrollToTopButton = newValue < -150 + } + } + } + .frame(height: 0) + .id(topID) + + // 타이틀 + Text("재고 관리") + .font(.title2) + .bold() + .padding(.top, 13) + .padding(.leading, 25) + .frame(maxWidth: .infinity, alignment: .leading) + + GridMenuView() + + Text("얼마 남지 않았어요!") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 25) + .padding(.top) + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVStack(spacing: 12) { + ForEach(inventoryViewModel.underLimitItems) { item in + InventoryCardView(item: item) + .padding(.horizontal) + .onAppear { + if item.id == inventoryViewModel.underLimitItems.last?.id { + Task { + await inventoryViewModel.loadUnderLimitList() + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical, 20) + } + } + .padding(.top, 8) + .padding(.bottom, 80) + } + } + + // 오른쪽 하단 플로팅 버튼 + if showScrollToTopButton { + VStack { + Spacer() + HStack { + Spacer() + Button { + withAnimation(.easeInOut) { + proxy.scrollTo(topID, anchor: .top) + } + } label: { + ZStack { + Circle() + .fill(Color.Primary) // 배경색 + .frame(width: 50, height: 50) + Image(systemName: "chevron.up") + .font( + .system(size: 14, weight: .semibold) + ) + .foregroundColor(.white) // 화살표 색 + } + } + .padding(.trailing, 20) + .padding(.bottom, 15) + } + } + .transition(.opacity) + .animation(.easeInOut(duration: 0.25), value: showScrollToTopButton) + } + } + .background(Color.Light) + .task { + await inventoryViewModel.loadUnderLimitList(reset: true) + } + // 같은 탭 다시 눌릴 때 위로 스크롤 + .onChange(of: tabTappedTrigger) { _ in + withAnimation(.easeInOut) { + proxy.scrollTo(topID, anchor: .top) + } + } + } + } + } +} + +struct GridMenuView: View { + let menuItems = [ + ("재고 조회", true, "InvStock", AnyView(InventorySearchView())), + ("입출고 히스토리", false, "InvTrans", AnyView(InOutHistoryView())), + ("입고 처리", false, "InvIncoming", AnyView(IncomingScanView())), + ("사용 처리", true, "InvUse", AnyView(OutgoingScanView().environmentObject(PartStore()))), + ] + + var body: some View { + VStack(spacing: 15) { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ], + spacing: 10 + ) { + ForEach(menuItems, id: \.0) { item in + NavigationLink(destination: item.3) { + VStack(alignment: .leading, spacing: 19) { + HStack { + Spacer() + ZStack { + // 타원 배경 + RoundedRectangle(cornerRadius: 12) + .fill(item.1 ? Color.white.opacity(0.28) : Color.Primary.opacity(0.15)) + .frame(width: 32, height: 32) + + // 아이콘 + Image(item.2) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 19, height: 19) + .foregroundColor(item.1 ? .white : .Primary) + } + } + .padding(.top, 34) + + + Text(item.0) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(item.1 ? .white : Color.Primary) + .padding(.leading, 5) + .padding(.top, 5) + .padding(.bottom, 40) + } + .padding(12) + .frame(height: 99) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(item.1 ? Color.Primary : Color.white) + .shadow(color: .black.opacity(0.35), radius: 2, x: 0, y: 4) // 카드 그림자 (Figma 스펙: y=4, blur=4, opacity=25%, black) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 15) + } + .padding(.vertical, 17) + } +} diff --git a/StockMate/StockMate/app/feature/inventory/ui/LackListView.swift b/StockMate/StockMate/app/feature/inventory/ui/LackListView.swift new file mode 100644 index 0000000..def5f29 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/LackListView.swift @@ -0,0 +1,109 @@ +// +// LackListView.swift +// StockMate +// +// Created by Admin on 10/23/25. +// + +import SwiftUI + +struct LackListView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var inventoryViewModel = InventoryViewModel() + @State private var isFirstAppear = true + + // 전달받는 초기 카테고리 + @State var selectedCategory: String + private let categories = ["전기/램프", "엔진/미션", "하체/바디", "내장/외장", "기타소모품"] + + var body: some View { + VStack(spacing: 0) { + // 상단 카테고리 탭 (가로 스크롤 가능) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(categories, id: \.self) { category in + CategoryButton( + title: category, + isSelected: selectedCategory == category + ) { + Task { + selectedCategory = category + inventoryViewModel.selectedCategories = [category] + await inventoryViewModel.loadUnderLimitList(reset: true) + + // 버튼 클릭 시 해당 카테고리로 스크롤 이동 + withAnimation { + proxy.scrollTo(category, anchor: .center) + } + } + } + .id(category) // ScrollViewReader용 id + } + } + .padding(.horizontal) + .padding(.vertical, 10) + } + .onAppear { + // 진입 시 선택된 카테고리 위치로 자동 스크롤 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation { + proxy.scrollTo(selectedCategory, anchor: .center) + } + } + } + } + + + // 리스트 + ScrollView { + LazyVStack(spacing: 12) { + ForEach(inventoryViewModel.underLimitItems) { item in + InventoryCardView(item: item) + .padding(.horizontal) + .onAppear { + // 무한 스크롤 트리거 + if item.id == inventoryViewModel.underLimitItems.last?.id { + Task { + await inventoryViewModel.loadUnderLimitList() + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical, 20) + } + } + .padding(.top, 8) + .padding(.bottom, 80) + } + } + .background(Color.Light) + .navigationTitle("부족 재고") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + if isFirstAppear { + isFirstAppear = false + inventoryViewModel.selectedCategories = [selectedCategory] + await inventoryViewModel.loadUnderLimitList(reset: true) + } + } + } +} + 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..38a894e --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/OutgoingScanView.swift @@ -0,0 +1,195 @@ +// +// 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 = "" + + // 전역 부품 저장소 + @EnvironmentObject var partStore: PartStore + @StateObject private var partViewModel = PartViewModel() + + @State private var showBottomSheet = false + + @State private var scannerRestartTrigger = false + + @State private var partDetail: PartDetail? = nil + + + var body: some View { + ZStack { + // 카메라 미리보기 (QR 스캐너) + QRScannerView(scannedCode: $scannedCode, isActive: !showBottomSheet) + .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.Primary, lineWidth: 3) + .frame(width: 220, height: 220) + } + .padding(.bottom, 180) + + Spacer() + + } + + // 로딩 인디케이터 + if partViewModel.isLoading { + 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) { + 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 다시 활성화 + } + ) + .presentationDetents([.fraction(0.80)]) // 시트 높이 80% + .presentationCornerRadius(28) + .environmentObject(partStore) + } + .navigationTitle("부품 사용 처리") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + } + + // 스캔된 코드로 부품 상세 조회만 수행 (출고 X) + 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 호출 + await partViewModel.fetchPartDetail(partIds: partId) + + // 결과 출력 + await MainActor.run { + partViewModel.isLoading = false + + guard let response = partViewModel.partDetails.first else { + alertMessage = "부품 정보를 불러오지 못했습니다." + showAlert = true + return + } + + // 응답을 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 + + // 스캔 상태 초기화 (다시 스캔 가능하도록) + resetScanState() + + print("\(newPart.korName) 부품이 전역 Store에 추가됨") + } + } + + // 상태 초기화 (QR 다시 활성화) + private func resetScanState() { + scannedCode = nil + scannerRestartTrigger.toggle() + } +} 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..8e5b5cc --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/ui/UsedPartListSheetView.swift @@ -0,0 +1,222 @@ +// +// 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 { + 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(.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 // 바인딩으로 변경 ($ 붙임) + 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 { + partStore.decreaseQuantityOrRemove(for: part) + } label: { + Image(systemName: "minus") + .font(.system(size: 14, weight: .regular)) + .frame(width: 13,height: 13) + .foregroundColor(.black) + } + Text("\(part.quantity)") + .font(.system(size: 15, weight: .medium)) + .frame(width: 20) + Button { + partStore.increaseQuantity(for: part) + } 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 ) + } + } + .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) + + } + } + .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.Primary + ) + .cornerRadius(9999) + } + .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/inventory/viewmodel/InventoryViewModel.swift b/StockMate/StockMate/app/feature/inventory/viewmodel/InventoryViewModel.swift new file mode 100644 index 0000000..1b466b5 --- /dev/null +++ b/StockMate/StockMate/app/feature/inventory/viewmodel/InventoryViewModel.swift @@ -0,0 +1,293 @@ +// +// InventoryViewModel.swift +// StockMate +// +// Created by Admin on 10/15/25. +// + +import SwiftUI + +@MainActor +final class InventoryViewModel: ObservableObject { + // ===== 기본 상태 ===== + @Published var inventoryItems: [InventoryItem] = [] + @Published var message: String = "" + @Published var shouldGoToLogin: Bool = false + + // ====== 필터 상태 ====== + @Published var selectedCategories: [String] = [] + @Published var selectedTrims: [String] = [] + @Published var selectedModels: [String] = [] + + + // ===== 전체 재고 페이지네이션 ===== + @Published var currentPage = 0 + @Published var hasMore = true + + // ====== 부족 재고 페이지네이션 ====== + @Published var underLimitItems: [InventoryItem] = [] + @Published var underLimitPage = 0 + @Published var underLimitHasMore = true + + // ====== 검색 관련 ====== + @Published var searchResults: [InventoryItem] = [] + @Published var searchPage = 0 + @Published var searchHasMore = true + @Published var isSearching = false + + // ====== 공통 상태 ====== + @Published var isLoading = false + + // ===== 카테고리별 부족 재고 개수 ===== + @Published var lackCounts: [LackCountItem] = [] + + + private let repo: InventoryRepositoryProtocol + + init(repo: InventoryRepositoryProtocol = InventoryRepositoryImpl()) { + self.repo = repo + } + + // MARK: - 전체 재고 로드 + func loadInventoryList(reset: Bool = false, size: Int = 20) async { + guard !isLoading else { return } + isLoading = true + isSearching = false // 검색 모드 해제 + + if reset { + currentPage = 0 + inventoryItems.removeAll() + hasMore = true + } + + let result = await repo.getInventoryList( + page: currentPage, + size: size, + categoryNames: selectedCategories, + trims: selectedTrims, + models: selectedModels + ) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + inventoryItems = data.content + } else { + inventoryItems.append(contentsOf: data.content) + } + hasMore = currentPage + 1 < data.totalPages + currentPage += 1 + } else { + message = apiResp.message + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + isLoading = false + } + + + func resetAndLoad() async { + await loadInventoryList(reset: true) + } + + // MARK: - 부족 재고 로드 + func loadUnderLimitList(reset: Bool = false, size: Int = 10) async { + guard !isLoading, (underLimitHasMore || reset) else { return } + isLoading = true + + if reset { + underLimitPage = 0 + underLimitItems.removeAll() + underLimitHasMore = true + } + + // 여기서 under-limit API 호출 + let result = await repo.getUnderLimitList( + categoryName: selectedCategories.first, // 필터 적용 시 사용 + page: underLimitPage, + size: size + ) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + underLimitItems = data.content + } else { + underLimitItems.append(contentsOf: data.content) + } + underLimitHasMore = underLimitPage + 1 < data.totalPages + underLimitPage += 1 + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + isLoading = false + } + + // MARK: - 이름 검색 + func searchByName(name: String, reset: Bool = false, size: Int = 20) async { + guard !isLoading else { return } + isLoading = true + isSearching = true + + if reset { + searchPage = 0 + searchResults.removeAll() + searchHasMore = true + } + + let result = await repo.findByName(name: name, page: searchPage, size: size) + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + if reset { + searchResults = data.content + } else { + searchResults.append(contentsOf: data.content) + } + searchHasMore = searchPage + 1 < data.totalPages + searchPage += 1 + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + + isLoading = false + } + + // MARK: - 검색 결과에서 로컬 필터링 + var filteredSearchResults: [InventoryItem] { + searchResults.filter { item in + // 카테고리 필터 + if !selectedCategories.isEmpty && !selectedCategories.contains(item.categoryName) { + return false + } + // 트림 필터 + if !selectedTrims.isEmpty && !selectedTrims.contains(item.trim) { + return false + } + // 모델 필터 + if !selectedModels.isEmpty && !selectedModels.contains(item.model) { + return false + } + return true + } + } + + // MARK: - 필터 토글 (검색모드 해제 + 전체 재로드) + func toggleCategory(_ name: String) { + if selectedCategories.contains(name) { + selectedCategories.removeAll { $0 == name } + } else { + selectedCategories.append(name) + } + // 검색 중일 때는 로컬 필터링만 다시 계산 + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } + } + + func toggleTrim(_ trim: String) { + if selectedTrims.contains(trim) { + selectedTrims.removeAll { $0 == trim } + } else { + selectedTrims.append(trim) + } + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } + } + + func toggleModel(_ model: String) { + if selectedModels.contains(model) { + selectedModels.removeAll { $0 == model } + } else { + selectedModels.append(model) + } + if isSearching { + objectWillChange.send() + } else { + Task { await resetAndLoad() } + } + } + + // MARK: - 필터 초기화 + func resetFilters(with searchText: String) { + // 1. 필터 관련 선택 초기화 + selectedCategories.removeAll() + selectedTrims.removeAll() + selectedModels.removeAll() + + // 2. 검색어가 없을 경우 → 전체 재고 다시 + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + isSearching = false + searchResults.removeAll() + // searchPage = 0 + Task { await loadInventoryList(reset: true) } + } + // 3. 검색어가 있는 경우 → 해당 검색어로 전체 결과 다시 검색 + else { + isSearching = true + searchResults.removeAll() + Task { + await searchByName( + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + reset: true + ) + } + } + } + + // MARK: - 무한 스크롤 로드 + func loadMore(searchText: String) async { + if isSearching { + guard !searchText.trimmingCharacters(in: .whitespaces).isEmpty else { return } + await searchByName(name: searchText) + } else { + await loadInventoryList() + } + } + + + // MARK: - 카테고리별 부족 재고 개수 로드 + func loadLackCountByCategory() async { + guard !isLoading else { return } + isLoading = true + + let result = await repo.getLackCountByCategory() + + switch result { + case .success(let apiResp): + if let data = apiResp.data { + lackCounts = data + } else { + message = apiResp.message + } + case .failure(let err): + message = err.message + if err.code == 401 || err.code == 403 { + shouldGoToLogin = true + } + } + + isLoading = false + } + +} diff --git a/StockMate/StockMate/app/feature/notification/data/NotificationApi.swift b/StockMate/StockMate/app/feature/notification/data/NotificationApi.swift new file mode 100644 index 0000000..a2513c7 --- /dev/null +++ b/StockMate/StockMate/app/feature/notification/data/NotificationApi.swift @@ -0,0 +1,53 @@ +// +// NotificationApi.swift +// StockMate +// +// Created by Admin on 11/7/25. +// + +import Foundation +import Alamofire + +// MARK: - 알림 데이터 구조 +struct NotificationItem: Decodable, Identifiable { + let id: Int + let orderId: Int + let orderNumber: String + let message: String + let createdAt: String + let read: Bool +} + +// MARK: - 알림 관련 API +enum NotificationApi { + + // MARK: 알림 전체 조회 + static func getAllNotifications() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/store/notifications/" + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 읽지 않은 알림 개수 조회 + static func getUnreadCount() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/store/notifications/unread/count" + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 읽지 않은 알림 목록 조회 + static func getUnreadNotifications() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/store/notifications/unread" + return ApiClient.shared.request(url, method: .get) + } + + // MARK: 전체 알림 읽음 처리 + static func markAllAsRead() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/store/notifications/read-all" + return ApiClient.shared.request(url, method: .patch) + } + + // MARK: 개별 알림 읽음 처리 + static func markAsRead(notificationId: Int) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/store/notifications/read?notificationId=\(notificationId)" + return ApiClient.shared.request(url, method: .patch) + } +} diff --git a/StockMate/StockMate/app/feature/notification/data/NotificationRepositoryImpl.swift b/StockMate/StockMate/app/feature/notification/data/NotificationRepositoryImpl.swift new file mode 100644 index 0000000..47d6593 --- /dev/null +++ b/StockMate/StockMate/app/feature/notification/data/NotificationRepositoryImpl.swift @@ -0,0 +1,41 @@ +// +// NotificationRepositoryImpl.swift +// StockMate +// +// Created by Admin on 11/7/25. +// + +import SwiftUI +import Alamofire + +// 알림 관련 API 호출을 담당하는 Repository 구현체 +final class NotificationRepositoryImpl: NotificationRepositoryProtocol { + // 전체 알림 목록 조회 + func getAllNotifications() async -> AppResult> { + let request = NotificationApi.getAllNotifications() + return await safeApi(request, decodeTo: ApiResponse<[NotificationItem]>.self) + } + // 읽지 않은 알림 개수 조회 + func getUnreadCount() async -> AppResult> { + let request = NotificationApi.getUnreadCount() + return await safeApi(request, decodeTo: ApiResponse.self) + } + + // 읽지 않은 알림 목록 조회 + func getUnreadNotifications() async -> AppResult> { + let request = NotificationApi.getUnreadNotifications() + return await safeApi(request, decodeTo: ApiResponse<[NotificationItem]>.self) + } + + // 모든 알림을 읽음 처리 + func markAllAsRead() async -> AppResult> { + let request = NotificationApi.markAllAsRead() + return await safeApi(request, decodeTo: ApiResponse.self) + } + + // 특정 알림을 읽음 처리 + func markAsRead(notificationId: Int) async -> AppResult> { + let request = NotificationApi.markAsRead(notificationId: notificationId) + return await safeApi(request, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/notification/domain/NotificationRepositoryProtocol.swift b/StockMate/StockMate/app/feature/notification/domain/NotificationRepositoryProtocol.swift new file mode 100644 index 0000000..07fe713 --- /dev/null +++ b/StockMate/StockMate/app/feature/notification/domain/NotificationRepositoryProtocol.swift @@ -0,0 +1,27 @@ +// +// NotificationRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 11/7/25. +// + +import SwiftUI +import Alamofire + + +protocol NotificationRepositoryProtocol { + // 전체 알림 조회 + func getAllNotifications() async -> AppResult> + + // 읽지 않은 알림 개수 조회 + func getUnreadCount() async -> AppResult> + + // 읽지 않은 알림 조회 + func getUnreadNotifications() async -> AppResult> + + // 전체 알림 읽음 처리 + func markAllAsRead() async -> AppResult> + + // 개별 알림 읽음 처리 + func markAsRead(notificationId: Int) async -> AppResult> +} diff --git a/StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift b/StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift new file mode 100644 index 0000000..7df3f6a --- /dev/null +++ b/StockMate/StockMate/app/feature/notification/ui/NotificationListView.swift @@ -0,0 +1,104 @@ +// +// NotificationListView.swift +// StockMate +// +// Created by Admin on 11/7/25. +// +import SwiftUI + +struct NotificationListView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var notificationViewModel = NotificationViewModel() + @State private var selectedOrderId: Int? = nil + + var body: some View { + VStack { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(notificationViewModel.notifications) { notification in + NotificationCardView(item: notification) { + Task { + await notificationViewModel.markAsRead(notification.id) + selectedOrderId = notification.orderId + } + } + } + } + .padding() + } + } + .background(Color.Light) + .navigationTitle("알림") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("전체 읽음") { + Task { await notificationViewModel.markAllAsRead() } + } + .foregroundColor(.red) + .font(.subheadline) + } + } + .navigationDestination(item: $selectedOrderId) { orderId in + OrderDetailView(orderId: orderId, orderViewModel: OrderViewModel()) + } + .task { + await notificationViewModel.fetchNotifications() + } + } +} + + +struct NotificationCardView: View { + let item: NotificationItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(alignment: .center, spacing: 14) { + Image("notiImage") + .resizable() + .scaledToFit() + .frame(width: 38, height: 38) + + VStack(alignment: .leading, spacing: 6) { + Text(item.message) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.primary) + Text(item.orderNumber) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.gray) + Text(formattedDate(item.createdAt)) + .font(.caption) + .foregroundColor(.gray) + } + Spacer() + + // 안 읽은 알림 표시 삘간 점 + if !item.read { + Circle() + .fill(Color.red) + .frame(width: 7, height: 7) + .padding(.trailing) + } + + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 1) + } + } +} diff --git a/StockMate/StockMate/app/feature/notification/viewmodel/NotificationViewModel.swift b/StockMate/StockMate/app/feature/notification/viewmodel/NotificationViewModel.swift new file mode 100644 index 0000000..8c5d479 --- /dev/null +++ b/StockMate/StockMate/app/feature/notification/viewmodel/NotificationViewModel.swift @@ -0,0 +1,86 @@ +// +// NotificationViewModel.swift +// StockMate +// +// Created by Admin on 11/7/25. +// + +import Foundation + +// 알림 데이터를 관리하고, API 요청을 통해 상태를 갱신하는 ViewModel +@MainActor +final class NotificationViewModel: ObservableObject { + @Published var notifications: [NotificationItem] = [] + @Published var unreadCount: Int = 0 + @Published var isLoading = false + + // 알림 데이터 요청용 Repository + private let repository: NotificationRepositoryProtocol = NotificationRepositoryImpl() + + // 전체 알림 조회 + func fetchNotifications() async { + isLoading = true + defer { isLoading = false } + + let result = await repository.getAllNotifications() + switch result { + case .success(let response): + notifications = (response.data ?? []).sorted { $0.createdAt > $1.createdAt } + case .failure(let error): + print("❌ 알림 조회 실패:", error.localizedDescription) + } + } + + // 읽지 않은 개수 조회 + func fetchUnreadCount() async { + let result = await repository.getUnreadCount() + switch result { + case .success(let response): + unreadCount = response.data ?? 0 + case .failure(let error): + print("❌ 읽지 않은 개수 조회 실패:", error.localizedDescription) + } + } + + // 개별 알림 읽음 처리 + func markAsRead(_ id: Int) async { + let result = await repository.markAsRead(notificationId: id) + switch result { + case .success: + if let index = notifications.firstIndex(where: { $0.id == id }) { + notifications[index] = NotificationItem( + id: notifications[index].id, + orderId: notifications[index].orderId, + orderNumber: notifications[index].orderNumber, + message: notifications[index].message, + createdAt: notifications[index].createdAt, + read: true + ) + } + unreadCount = max(0, unreadCount - 1) // 카운트 즉시 반영 + case .failure(let error): + print("❌ 알림 읽음 처리 실패:", error.localizedDescription) + } + } + + // 전체 읽음 처리 + func markAllAsRead() async { + let result = await repository.markAllAsRead() + switch result { + case .success: + for i in 0.. DataRequest { + var url = ApiClient.baseURL + "api/v1/order/list/my?page=\(page)&size=\(size)" + + if let status = status, !status.isEmpty { + url += "&status=\(status.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + if let startDate = startDate, !startDate.isEmpty { + url += "&startDate=\(startDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + if let endDate = endDate, !endDate.isEmpty { + url += "&endDate=\(endDate.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + } + + return ApiClient.shared.request(url, method: .get) + } + + + // 주문 상세 조회 API + static func getOrderDetail(orderId: Int) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/detail?orderId=\(orderId)" + return ApiClient.shared.request(url, method: .get) + } + + + // 주문 생성 API + static func createOrder(_ requestBody: OrderRequest) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order" + return ApiClient.shared.request( + url, + method: .post, + parameters: requestBody, + encoder: JSONParameterEncoder.default + ) + } + + // 주문 취소 API + static func cancelOrder(orderId: Int) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/order/\(orderId)/cancel" + print("CancelOrder URL:", url) + 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 new file mode 100644 index 0000000..6de6a5b --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/data/OrderRepositoryImpl.swift @@ -0,0 +1,109 @@ +// +// OrderRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/21/25. +// + +import Foundation +import Alamofire + +final class OrderRepositoryImpl: OrderRepositoryProtocol { + + // 내 주문 목록 조회 + func fetchMyOrders( + status: String?, + startDate: String?, + endDate: String?, + page: Int, + size: Int + ) async -> AppResult { + let request = OrderApi.getMyOrderList( + status: status, + startDate: startDate, + endDate: endDate, + page: page, + size: size + ) + + // 서버 응답을 OrderListResponse 형태로 디코딩 + let result = await safeApi(request, decodeTo: OrderListResponse.self) + + switch result { + case .success(let response): + if let pageData = response.data { // response.data 는 OrderPageData? 이므로 안전하게 꺼내서 반환 + return .success(pageData) + } else { + // data가 없을 경우 서버 메시지로 실패 처리 + return .failure(AppError(code: response.status, message: response.message, underlying: nil)) + } + + case .failure(let error): + return .failure(error) + } + } + + // 주문 상세 조회 + func fetchOrderDetail(orderId: Int) async -> AppResult { + let request = OrderApi.getOrderDetail(orderId: orderId) + let result = await safeApi(request, decodeTo: OrderDetailResponse.self) + + switch result { + case .success(let response): + if let data = response.data { + return .success(data) + } else { + return .failure(.init(code: -1, message: "주문 상세 데이터를 불러오지 못했습니다.", underlying: nil)) + } + case .failure(let error): + return .failure(error) + } + } + + // 주문 생성 + func createOrder(request: OrderRequest) async -> AppResult { + let request = OrderApi.createOrder(request) + + let result = await safeApi(request, decodeTo: ApiResponse.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) + } + } + + // 주문 취소 + func cancelOrder(orderId: Int) async -> AppResult { + let request = OrderApi.cancelOrder(orderId: orderId) + let result = await safeApi(request, decodeTo: ApiResponse.self) + + switch result { + case .success(let response): + print(" 취소 성공:", response) + return .success(response.data ?? "success") + case .failure(let error): + return .failure(error) + } + } + + // 입고 처리 + 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 new file mode 100644 index 0000000..f2837be --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/domain/OrderRepositoryProtocol.swift @@ -0,0 +1,32 @@ +// +// OrderRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/21/25. +// + +import Foundation + +protocol OrderRepositoryProtocol { + func fetchMyOrders( + status: String?, + startDate: String?, + endDate: String?, + page: Int, + size: Int + ) async -> AppResult + + // 주문 상세 조회 + func fetchOrderDetail( + orderId: Int + ) async -> AppResult + + // 주문 생성 + 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/OrderCartView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderCartView.swift new file mode 100644 index 0000000..ba66504 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderCartView.swift @@ -0,0 +1,92 @@ +// +// OrderCartView.swift +// StockMate +// +// Created by Admin on 10/20/25. +// + +import SwiftUI + +struct OrderCartView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var cartViewModel: CartViewModel + + var body: some View { + VStack(spacing: 0) { + + if cartViewModel.items.isEmpty { + // 장바구니 비어있을 때 + VStack(spacing: 8) { + Text("장바구니가 비어있어요.") + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.black) + Text("부품을 담아보세요.") + .font(.system(size: 13)) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.Light) + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(cartViewModel.items) { cartItem in + + CartCard( + item: cartItem, + quantity: cartItem.amount, + onIncrease: { + Task { await cartViewModel.increaseQuantity(for: cartItem.partId) } + }, + onDecrease: { + Task { await cartViewModel.decreaseQuantity(for: cartItem.partId) } + } + ) + .padding(.horizontal) + } + } + .padding(.vertical) + } + .background(Color.Light) + } + + NavigationLink(destination: OrderInfoView(cartViewModel: cartViewModel)) { + Text("\(cartViewModel.cart?.totalPrice ?? 0)원 결제하기") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(cartViewModel.items.isEmpty ? Color.gray.opacity(0.3) : Color.Primary) + ) + .padding(.horizontal, 16) + .padding(.bottom, 30) + } + .disabled(cartViewModel.items.isEmpty) + + + } + .background(Color.Light) + .navigationTitle("장바구니 확인") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + await cartViewModel.fetchCart() + } + .edgesIgnoringSafeArea(.bottom) + } +} + diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift new file mode 100644 index 0000000..bdc8067 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderDetailView.swift @@ -0,0 +1,486 @@ +// +// OrderDetailView.swift +// StockMate +// +// Created by Admin on 10/22/25. +// + +import SwiftUI + +struct OrderDetailView: View { + let orderId: Int + @Environment(\.dismiss) private var dismiss + @ObservedObject var orderViewModel: OrderViewModel + @StateObject private var viewModel = OrderDetailViewModel() + + // 입고처리 버튼 + 모달 + 리프레시 + @State private var showSuccessModal = false // 모달 표시용 상태 + @State private var refreshTrigger = UUID() // 화면 리프레시 트리거 + + + var body: some View { + ScrollView { + if viewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let order = viewModel.order { + VStack(spacing: 10) { + VStack { + DeliveryStatusView(currentStep: deliveryStep(for: order.orderStatus)) + .frame(maxWidth: .infinity, alignment: .center) + } + .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){ + Text("상태") + .font(.system(size: 14)) + Spacer() + Text(statusText(order.orderStatus)) + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(statusBdColor(order.orderStatus)) + .foregroundColor(statusColor(order.orderStatus)) + .cornerRadius(12) + } + + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.all, 20) + .background(Color.white) + .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("배송정보") + .font(.system(size: 15, weight: .semibold)) + .padding(.bottom, 4) + + infoRow("주문자명", order.userInfo?.owner ?? "-") + infoRow("주소", order.userInfo?.address ?? "-") + // 운송장정보 안전 처리 + let trackingText: String = { + if let carrier = order.carrier, + let trackingNo = order.trackingNumber, + !carrier.isEmpty, + !trackingNo.isEmpty { + return "(\(carrier): \(trackingNo))" + } + 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) + + + // 요청사항이 있는 경우에만 표시 + if let etc = order.etc, !etc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("요청사항") + .font(.system(size: 15, weight: .semibold)) + .padding(.bottom, 4) + + Text(etc) + .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) + } + + // 주문 상품 + OrderSectionCard { + VStack(alignment: .leading, spacing: 6) { + Text("주문 상품 \(order.orderItems.count)개") + .font(.system(size: 15, weight: .semibold)) + + VStack(alignment: .leading, spacing: 6) { + ForEach(order.orderItems, id: \.partId) { item in + Text(item.partDetail.categoryName) + .font(.system(size: 12, weight: .semibold)) + + HStack(alignment: .top, spacing: 12) { + AsyncImage(url: URL(string: item.partDetail.image)) { img in + img.resizable().scaledToFill() + } placeholder: { + Color.gray.opacity(0.1) + } + .frame(width: 60, height: 60) + .cornerRadius(4) + .clipped() + + VStack(alignment: .leading, spacing: 4) { + Text(item.partDetail.korName) + .font(.system(size: 14, weight: .semibold)) + Text("\(item.partDetail.model) / \(item.partDetail.trim) / \(formatPrice(item.partDetail.price))원 / \(item.amount)개") + .font(.caption) + .foregroundColor(.gray) + Text("\(formatPrice(item.partDetail.price * item.amount))원") + .font(.system(size: 14, weight: .bold)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 8) // 위 여백만 살짝 + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + + // 결제 정보 + OrderSectionCard { + VStack(alignment: .leading, spacing: 10) { + Text("결제 정보") + .font(.system(size: 15, weight: .semibold)) + + if order.paymentType == "DEPOSIT" { + infoRow("결제 수단", "예치금") + } else { + infoRow("결제 수단", "카드 결제") + } + + infoRow("상품금액", "\(formatPrice(order.totalPrice))원") + infoRow("배송희망일", formatDateOrDash(order.requestedShippingDate)) + Divider().padding(.vertical, 4) + HStack { + Text("총 결제 금액") + .font(.headline) + Spacer() + Text("\(formatPrice(order.totalPrice))원") + .font(.headline.bold()) + .foregroundColor(.Primary) + } + } + } + + // 오른쪽 버튼: 주문 상태에 따라 변경 + if order.orderStatus == "ORDER_COMPLETED" || + order.orderStatus == "PAY_COMPLETED" || + order.orderStatus == "SHIPPING"{ + + HStack(spacing: 12) { + // 왼쪽: 영수증 확인 + NavigationLink(destination: ReceiptView(orderId: order.id)) { + Text("영수증 확인") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.Primary) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.Primary, lineWidth: 1.5) + ) + .cornerRadius(10) + } + + + // 오른쪽 버튼: 주문 상태에 따라 변경 + if order.orderStatus == "ORDER_COMPLETED" || + order.orderStatus == "PAY_COMPLETED" { + // "주문취소" → 주문완료/결제완료/승인대기 + Button(action: { + Task { + await orderViewModel.cancelOrder(orderId: orderId) + } + }) { + Text("주문 취소") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color(hex: "#1D4ED8")) + .foregroundColor(.white) + .cornerRadius(10) + } + + } else { + // "입고 하기" → 배송중 + Button(action: { + Task { + let result = await orderViewModel.receiveOrder(orderNumber: order.orderNumber) + switch result { + case .success(let message): + showSuccessModal = true // 입고 처리 성공 시 모달 표시 + print("입고 처리 성공: \(message)") + case .failure(let error): + print("입고 처리 실패: \(error.message)") + // 실패 토스트 띄우기 등 + } + } + }) { + Text("입고 처리") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color(hex: "#1D4ED8")) + .foregroundColor(.white) + .cornerRadius(10) + } + + + } + } + .padding(.top, 5) + } else{ + // 나머지 상태: 영수증 확인 버튼만 하나 + NavigationLink(destination: ReceiptView(orderId: order.id)) { + Text("영수증 확인") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.Primary) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.Primary, lineWidth: 1.5) + ) + .cornerRadius(10) + } + .padding(.top, 5) + } + + } + .padding(.horizontal, 20) // 전체 섹션 동일 여백 + .padding(.vertical, 16) + + } else if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .overlay( + Group { + if showSuccessModal { + Color.black.opacity(0.4) + .ignoresSafeArea() + .overlay( + AlertModal( + icon: Image("SuccessIllust"), + title: "입고 처리 완료!", + message: "입고 부품 등록이 완료되었습니다.", + primaryButtonTitle: "확인", + primaryAction: { + showSuccessModal = false + // 화면 리프레시 트리거 + refreshTrigger = UUID() + Task { + await viewModel.fetchOrderDetail(orderId: orderId) + } + } + ) + ) + } + } + ) + .animation(.easeInOut, value: showSuccessModal) + .background(Color.Light) + .navigationTitle("주문 내역 상세") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + await viewModel.fetchOrderDetail(orderId: orderId) + } + .id(refreshTrigger) + + } + + // MARK: - Helper + func infoRow(_ left: String, _ right: String) -> some View { + HStack { + Text(left) + Spacer() + Text(right) + } + .font(.system(size: 14)) + } + + +} + +// 카드 레이아웃 통일용 +struct OrderSectionCard: View { + let content: Content + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + .padding(20) + } + .frame(maxWidth: .infinity) + .background(Color.white) + .cornerRadius(16) + .shadow(color: .black.opacity(0.05), radius: 3, y: 2) + } +} + +func formatPrice(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" +} + +func formatDateOrDash(_ isoDate: String?) -> String { + guard let isoDate = isoDate, !isoDate.isEmpty else { + return "-" + } + let comps = isoDate.split(separator: "T").first?.split(separator: "-") ?? [] + guard comps.count == 3 else { return "-" } + return "\(comps[0])년 \(comps[1])월 \(comps[2])일" +} + +func deliveryStep(for status: String) -> Int { + // 6 -> 전체 회색 + // 4 -> 전체 파란색 + switch status { + case "ORDER_COMPLETED": return 0 // 주문 완료 + + // 결제 후 결과에 따라 결제 실패 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 // 배송중 + + // 지점에서 QR을 스캔하여 입고 완료 처리 + case "RECEIVED": return 4 // 입고 완료 + default: return 6 + } +} + +func formatDate(_ isoDate: String) -> String { + let comps = isoDate.split(separator: "T").first?.split(separator: "-") ?? [] + guard comps.count == 3 else { return isoDate } + return "\(comps[0])년 \(comps[1])월 \(comps[2])일" +} + +// 0: 초록, 1: 빨강, 2: 주황, 3: 노랑, 4: 파랑, 5: 보라 + +func statusText(_ status: String) -> String { + switch status { + case "ORDER_COMPLETED": 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 "배송중" // 배송중 + + // 지점에서 QR을 스캔하여 입고 완료 처리 + case "RECEIVED": return "입고 완료" // 입고 완료 + default: return "알 수 없음" + } +} + +func statusColor(_ status: String) -> Color { + switch status { + case "ORDER_COMPLETED": return .StatusGreen + + case "FAILED": return .Danger + case "PAY_COMPLETED": return .StatusGreen + + case "CANCELLED": return .Danger + + case "REJECTED": return .Danger + case "APPROVAL_ORDER": return .Warning + + case "PENDING_SHIPPING": return .InvUse + case "SHIPPING": return .Secondary + + case "RECEIVED": return .StatusPurple + default: return .gray.opacity(0.6) + } +} + +func statusBdColor(_ status: String) -> Color { + switch status { + case "ORDER_COMPLETED": return .StatusGreenBg + + case "FAILED": return .DangerBg + case "PAY_COMPLETED": return .StatusGreenBg + + case "CANCELLED": return .DangerBg + + case "REJECTED": return .DangerBg + case "APPROVAL_ORDER": return .WarningBg + + case "PENDING_SHIPPING": return .InvUseBg + case "SHIPPING": return .LightBlue04 + + case "RECEIVED": return .StatusPurpleBg + default: return .gray.opacity(0.6) + } +} diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift new file mode 100644 index 0000000..3fa994e --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderInfoView.swift @@ -0,0 +1,534 @@ +// +// OrderInfoView.swift +// StockMate +// +// Created by Admin on 10/20/25. +// +import SwiftUI + +enum PaymentType: String { + case deposit = "DEPOSIT" + case card = "CARD" +} + +enum ShippingDateOption { + case today + case tomorrow + case specific(Date?) +} + +struct OrderInfoView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var cartViewModel: CartViewModel + @StateObject var orderViewModel = OrderViewModel() + @StateObject private var depositViewModel = DepositViewModel() + @StateObject private var userViewModel = UserViewModel() + + @State private var paymentType: PaymentType = .deposit + @State private var shippingDateOption: ShippingDateOption = .today + @State private var specificDate: Date? = nil + @State private var requestMessage: String = "" + + // 토스트 메세지 관련 + @State private var showDepositToast = false // 예치금 부족 + @State private var showChargeToast = false // 충전 완료 + + + // 모달 관련 상태 + @State private var showOrderSuccessModal = false + @State private var navigateToOrderDetail = false + @State private var navigateToHome = false + + private var destinationView: some View { + Group { + if navigateToOrderDetail, let id = orderViewModel.createdOrderId { + OrderDetailView(orderId: id, orderViewModel: orderViewModel) + } else if navigateToHome { + HomeView() + } else { + EmptyView() + } + } + } + + func formattedShippingDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "ko_KR") + + switch shippingDateOption { + case .today: + return formatter.string(from: Date()) + case .tomorrow: + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) ?? Date() + return formatter.string(from: tomorrow) + case .specific(let date): + return formatter.string(from: date ?? Date()) // nil이면 오늘 날짜로 fallback + } + } + + func makeOrderItems() -> [OrderItems] { + return cartViewModel.items.map { + OrderItems(partId: $0.id, amount: $0.amount) + } + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + contentView + } + .onTapGesture { + UIApplication.shared.hideKeyboard() // 화면 아무데나 탭하면 키보드 내려감 + } + .padding(.horizontal) + .padding(.top) + + bottomOrderButton + } + .toast( + isPresented: $showChargeToast, + message: "충전이 완료되었습니다.", + iconName: "checkmark", + iconColor: .green + ) + // 예치금 부족 토스트 + .toast( + isPresented: $showDepositToast, + message: "예치금이 부족합니다. (부족: \(formatPrice((cartViewModel.cart?.totalPrice ?? 0) - depositViewModel.balance))원)", + iconName: "info.circle", + iconColor: .LightBlue04 + ) + .background(Color.Light) + .navigationTitle("주문/결제") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .task { + await cartViewModel.fetchCart() + await depositViewModel.fetchDepositAmount() + await userViewModel.loadUserInfo() + + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .edgesIgnoringSafeArea(.bottom) + .onChange(of: orderViewModel.isOrderSuccess) { success in + if success { + Task { + // 1) 서버에 반영된 장바구니를 먼저 비운다 (await) + await cartViewModel.clearCart() + // 2) cart가 비워진 후에 모달을 띄운다 + // (모달을 띄우기 전에 createdOrderId는 orderViewModel에 이미 세팅되어 있어야 함) + showOrderSuccessModal = true + } + } + } + .sheet(isPresented: $depositViewModel.showChargeSheet) { + DepositChargeView(viewModel: depositViewModel) { + // 충전 성공 시 토스트 표시 + withAnimation { + showChargeToast = true + } + } + .presentationDetents([.fraction(0.58)]) // 시트 높이 80% + .presentationCornerRadius(20) + } + // 모달 오버레이 (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() + } + } + ) + } + + +} + +// MARK: - UI 구성 View +extension OrderInfoView { + + private var contentView: some View { + VStack(alignment: .leading, spacing: 17) { + shippingInfoSection + orderListSection + paymentSection + shippingDateSection + totalPriceSection + + Spacer().frame(height: 10) + } + } + + private var shippingInfoSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("배송 정보") + .font(.headline) + + VStack(alignment: .leading, spacing: 4) { + Text(userViewModel.userInfo?.owner ?? "이름 없음") + .font(.system(size: 15, weight: .medium)) + .padding(.bottom, 4) + Text(userViewModel.userInfo?.address ?? "주소 없음") + .font(.system(size: 14)) + .padding(.bottom, 4) + .foregroundColor(.textGray1) + + Text("요청사항") + .font(.system(size: 14, weight: .medium)) + .padding(.top, 5) + + TextEditor(text: $requestMessage) + .font(.system(size: 14)) + .padding(10) + .onChange(of: requestMessage) { newValue in + if newValue.count > 50 { // 50자 제한 + requestMessage = String(newValue.prefix(50)) + } + } + .scrollContentBackground(.hidden) + .background(Color.white) + .frame(height: 93) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(requestMessage.isEmpty ? Color(.systemGray4) : Color.Primary, lineWidth: 1) + ) + + + HStack { + Spacer() + Text("\(requestMessage.count)/50") + .font(.system(size: 12)) + .foregroundColor(.gray) + .padding(.trailing, 2) + } + .padding(.bottom, -8) + } + .padding() + .padding(.bottom,7) + .background(Color.white) + .cornerRadius(16) + } + .padding(.leading, 5) + } + + private var orderListSection: some View { + VStack(alignment: .leading) { + Text("주문 목록 (\(cartViewModel.items.count))") + .font(.headline) + .padding(.leading, 5) + + Section { + LazyVStack(spacing: 8) { + ForEach(cartViewModel.items) { cartItem in + CartInfoCard(item: cartItem, quantity: cartItem.amount) + .padding(.horizontal, 5) + } + } + } + .background(Color.white) + .cornerRadius(16) + } + } + private var paymentSection: some View { + ZStack { + // 배경 이미지 적용 + Image("deposit_background") // ← 에셋에 넣은 이미지 이름 + .resizable() + .scaledToFill() + .frame(height: 190) + .clipped() + .cornerRadius(16.39) + + VStack(alignment: .leading, spacing: 12) { + VStack (alignment: .leading, spacing: 13){ + HStack { + Text("사용 가능 예치금") + .font(.system(size: 17, weight: .bold)) + .padding(.leading, 5) + .padding(.top, 25) + .foregroundColor(Color.white) + } + + HStack { + // 예치금 금액 표시 + if depositViewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("₩\(formatPrice(depositViewModel.balance))") + .font(.system(size: 26, weight: .bold)) + .foregroundColor(Color.white) + } + } + } + + Spacer() + + HStack { + Spacer() + Button { + depositViewModel.showChargeSheet = true // <-- $ 없이 할당 + } label: { + Text("충전") + .foregroundColor(Color.Primary) + .font(.system(size: 14, weight: .semibold)) + .padding(.vertical, 8) + .padding(.horizontal, 14) + .background(Color.white) + .cornerRadius(20) + } + .padding(.trailing, 5) + } + .padding(.bottom, 25) + } + .frame(height: 185) + .padding(20) + } + .frame(maxWidth: .infinity) + } + + private var shippingDateSection: some View { + VStack(alignment: .leading) { + Text("배송 요청일") + .font(.headline) + .padding(.leading, 5) + + VStack(alignment: .leading, spacing: 5) { + HStack { + RadioButtonRow(title: "오늘", selected: { + if case .today = shippingDateOption { return true } + return false + }()) { + shippingDateOption = .today + } + Spacer() + } + .frame(height: 35) + HStack { + RadioButtonRow(title: "내일", selected: { + if case .tomorrow = shippingDateOption { return true } + return false + }()) { + shippingDateOption = .tomorrow + } + Spacer() + } + .frame(height: 35) + + HStack (spacing: 30){ + RadioButtonRow(title: "날짜 선택", selected: { + if case .specific(_) = shippingDateOption { return true } + return false + }()) { + shippingDateOption = .specific(nil) + } + .padding(.trailing, 5) + + if case .specific(let selectedDate) = shippingDateOption { + CustomDatePickerField(date: Binding( + get: { selectedDate ?? Date() }, + set: { newValue in + specificDate = newValue + shippingDateOption = .specific(newValue) + } + ), isDateSelected: selectedDate != nil) + } + } + .frame(height: 35) + + } + .padding() + .background(Color.white) + .cornerRadius(16) + } + } + + private var totalPriceSection: some View { + HStack { + Text("결제금액") + .font(.system(size: 16, weight: .semibold)) + Spacer() + Text("\(cartViewModel.cart?.totalPrice ?? 0)원") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.Primary) + } + .padding() + .background(Color.white) + .cornerRadius(10) + } + + private var bottomOrderButton: some View { + VStack { + Button { + let totalPrice = cartViewModel.cart?.totalPrice ?? 0 + let deposit = depositViewModel.balance + + if totalPrice > deposit { + // 예치금 부족 + withAnimation { + showDepositToast = true + } + } else { + Task { + let orderRequest = OrderRequest( + orderItems: makeOrderItems(), + requestedShippingDate: formattedShippingDate(), + paymentType: paymentType.rawValue, + etc: requestMessage + ) + await orderViewModel.createOrder(request: orderRequest) + } + } + + } label: { + Text("\(cartViewModel.cart?.totalPrice ?? 0)원 결제하기") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(cartViewModel.items.isEmpty ? Color.gray.opacity(0.3) : Color.Primary) + ) + .padding(.horizontal, 16) + .padding(.bottom, 30) + } + } + } +} + +struct RadioButtonRow: View { + let title: String + var selected: Bool + var action: () -> Void + + var body: some View { + HStack { + Image(systemName: selected ? "circle.inset.filled" : "circle") + .foregroundColor(selected ? .Primary : .gray) + Text(title) + } + .onTapGesture { action() } + } +} + +struct CustomDatePickerField: View { + @Binding var date: Date + var isDateSelected: Bool + @State private var showPicker: Bool = false + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } + + var body: some View { + Button { + showPicker.toggle() + } label: { + HStack { + Text(isDateSelected ? dateFormatter.string(from: date) : "선택하세요") + .font(.system(size: 14)) + .foregroundColor(isDateSelected ? .black : .gray) + + Spacer() + + Image("cal") + .resizable() + .frame(width: 15, height: 15) + .scaledToFit() + } + .padding(10) + .frame(width: 140, height: 30) + .background( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(.systemGray4)) + ) + } + .sheet(isPresented: $showPicker) { + VStack { + DatePicker( + "", + selection: $date, + in: Date()..., + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + .padding() + + Button("완료") { + showPicker = false + } + .font(.headline) + .padding() + .frame(maxWidth: .infinity) + } + .presentationDetents([.medium]) + } + } +} diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift new file mode 100644 index 0000000..160593b --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderListView.swift @@ -0,0 +1,169 @@ +// +// OrderListView.swift +// StockMate +// +// Created by Admin on 10/21/25. +// + +import SwiftUI + +struct OrderListView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var orderViewModel = OrderViewModel() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + if orderViewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = orderViewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if orderViewModel.orders.isEmpty { + Text("주문 내역이 없습니다.") + .foregroundColor(.gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // 날짜별로 그룹화 (최신순) + let groupedOrders = Dictionary(grouping: orderViewModel.orders) { order in + order.createdAt.split(separator: "T").first.map(String.init) ?? "" + } + .sorted { $0.key > $1.key } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + ForEach(groupedOrders, id: \.key) { date, orders in + VStack(alignment: .leading, spacing: 12) { + Text(formatDate(String(date))) + .font(.headline) + .padding(.leading, 25) + .padding(.top) + + ForEach(orders) { order in + OrderListCardView(order: order, orderViewModel: orderViewModel) + } + } + } + } + .padding(.bottom) + } + .padding(.top) + } + } + .background(Color.Light) + .navigationTitle("주문 내역") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + await orderViewModel.loadOrders() + } + } + + 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 OrderListCardView: View { + let order: OrderResponseItem + @ObservedObject var orderViewModel: OrderViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + // 상단: 주문번호 + 상세 버튼 + HStack { + Text("주문 번호: \(order.orderNumber)") + .font(.caption) + .foregroundColor(.gray) + Spacer() + NavigationLink(destination: OrderDetailView(orderId: order.id, orderViewModel: orderViewModel)) { + Text("주문 상세 >") + .font(.caption) + .foregroundColor(.gray) + } + } + + Divider() + + HStack(alignment: .center, spacing: 12) { + // 대표 이미지 + AsyncImage(url: URL(string: order.orderItems.first?.partDetail.image ?? "")) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(width: 64, height: 64) + .cornerRadius(10) + + // 제품명 + 개수 + VStack(alignment: .leading, spacing: 4) { + if let first = order.orderItems.first { + Text(first.partDetail.korName) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.black) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + + if order.orderItems.count > 1 { + Text("외 \(order.orderItems.count - 1)개") + .font(.caption) + .foregroundColor(.gray) + } + } + } + + Spacer() + + // 상태 뱃지 + Text(statusText(order.orderStatus)) + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(statusBdColor(order.orderStatus)) + .foregroundColor(statusColor(order.orderStatus)) + .cornerRadius(15) + } + + // 주문취소 버튼 (필요 시) + if order.orderStatus == "PAY_COMPLETED" { + Button(action: { + // 주문취소 처리 + Task { + await orderViewModel.cancelOrder(orderId: order.id) + } + }) { + Text("주문 취소") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 40) + .background(Color.Primary) + .cornerRadius(6) + } + .padding(.top, 6) + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding(.horizontal) + } +} diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderRequestSearchView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderRequestSearchView.swift new file mode 100644 index 0000000..fd44969 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderRequestSearchView.swift @@ -0,0 +1,203 @@ +// +// OrderRequestSearchView.swift +// StockMate +// +// Created by Admin on 10/27/25. +// + +import SwiftUI + +struct OrderRequestSearchView: View { + @Environment(\.dismiss) private var dismiss + + @ObservedObject var cartViewModel: CartViewModel + @StateObject var inventoryViewModel = InventoryViewModel() + @State private var searchText = "" + + // 카테고리, 분류, 모델 + private let categories = ["전기/램프", "엔진/미션", "하체/바디", "내장/외장", "기타소모품"] + private let trims = ["준중형/소형", "중형", "대형", "SUV", "화물/트럭/승합", "수소/전기"] + private let trimToModels: [String: [String]] = [ + "준중형/소형": [ "아반떼MD", "아반떼AD", "아반떼CN7", "I30", "엑센트", "아이오닉", "벨로스터", "캐스퍼" ], + "중형": [ "NF소나타", "YF소나타", "LF소나타", "DN8소나타", "그랜저TG", "그랜저HG", "그랜저IG", "그랜저GN7", "I40" ], + "대형": ["제네시스BH", "에쿠스"], + "SUV": [ "베뉴", "코나OS", "코나SX2", "투싼IX", "투싼TL", "투싼NX4", "싼타페CM", "싼타페DM", "싼타페TM", "싼타페MX5", "맥스크루즈", "베라크루즈", "팰리세이드LX2", "팰리세이드LX3" ], + "화물/트럭/승합": [ "스타렉스", "그랜드스타렉스", "스타리아", "포터2", "쏠라티", "마이티", "메가트럭", "카운티" ], + "수소/전기": ["아이오닉5", "아이오닉6", "아이오닉9", "넥쏘FE", "넥쏘NH2"] + ] + + private var filteredModels: [String] { + if inventoryViewModel.selectedTrims.isEmpty { + return trimToModels.values.flatMap { $0 } + } else { + return inventoryViewModel.selectedTrims + .flatMap { trimToModels[$0] ?? [] } + } + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + // 검색창 + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + TextField("부품을 검색하세요.", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .onSubmit { + let term = searchText.trimmingCharacters( + in: .whitespacesAndNewlines + ) + guard !term.isEmpty else { return } + Task { + await inventoryViewModel + .searchByName(name: term, reset: true) + } + } + + if !searchText.isEmpty { + Button(action: { + searchText = "" + inventoryViewModel.isSearching = false + }) { + Image(systemName: "xmark") + .foregroundColor(.gray) + } + .buttonStyle(.plain) + .padding(.trailing,3) + } + } + .padding() + .background(Color(.white)) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.gray.opacity(0.4), lineWidth: 1) + ) + .padding(.horizontal) + .padding(.vertical) + + // 필터 및 초기화 버튼 + HStack(spacing: 10) { + FilterMenu( + title: "카테고리", + items: categories, + selectedItems: inventoryViewModel.selectedCategories, + onTap: { inventoryViewModel.toggleCategory($0) } + ) + + FilterMenu( + title: "분류", + items: trims, + selectedItems: inventoryViewModel.selectedTrims, + onTap: { inventoryViewModel.toggleTrim($0) } + ) + + FilterMenu( + title: "모델", + items: filteredModels, + selectedItems: inventoryViewModel.selectedModels, + onTap: { inventoryViewModel.toggleModel($0) } + ) + + // 초기화 버튼 + Button(action: { + inventoryViewModel.resetFilters(with: searchText) + }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor( + inventoryViewModel.selectedCategories.isEmpty && + inventoryViewModel.selectedTrims.isEmpty && + inventoryViewModel.selectedModels.isEmpty + ? .black + : .Primary + ) + .padding(.trailing, 8) + .rotationEffect(.degrees(35)) + } + .frame(maxWidth: .infinity, alignment: .trailing) + + } + .padding(.horizontal) + .padding(.bottom, 16) + + // 재고 리스트 + ScrollView { + LazyVStack(spacing: 10) { + + ForEach( + inventoryViewModel.isSearching + ? inventoryViewModel.filteredSearchResults // 검색 + 필터링 + : inventoryViewModel.inventoryItems + ) { item in + let qty = cartViewModel.quantity(for: item.id) + + OrderRequestCardView( + item: item, + quantity: qty, + onIncrease: { Task { await cartViewModel.increaseQuantity(for: item.id) } }, + onDecrease: { Task { await cartViewModel.decreaseQuantity(for: item.id) }}, + onAddToCart: { Task { await cartViewModel.addToCart(partId: item.id, amount: 1) }} + ) + .padding(.horizontal) + .onAppear { + Task { + if inventoryViewModel.isSearching { + if item.id == inventoryViewModel.filteredSearchResults.last?.id, + inventoryViewModel.searchHasMore { + await inventoryViewModel .loadMore(searchText: searchText) + } + } else { + if item.id == inventoryViewModel.inventoryItems.last?.id, + inventoryViewModel.hasMore, + !inventoryViewModel.isLoading + { + await inventoryViewModel.loadMore(searchText: searchText) + } + } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView() + .padding(.vertical) + } + } + } + } + .background(Color.Light) + .navigationTitle("직접 발주") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + await inventoryViewModel.loadInventoryList(reset: true) + await cartViewModel.fetchCart() + } + + VStack { + Spacer() + CartSummaryBar(cartVM: cartViewModel) + } + .ignoresSafeArea(edges: .bottom) + + } + .onTapGesture { + UIApplication.shared.hideKeyboard() + } + } +} diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderResultView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderResultView.swift new file mode 100644 index 0000000..0b89be5 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderResultView.swift @@ -0,0 +1,77 @@ +// +// OrderResultView.swift +// StockMate +// +// Created by Admin on 10/20/25. +// + +import SwiftUI + +struct OrderResultView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 24) { + + Spacer() + + // 결제 완료 아이콘 + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundColor(.blue) + .padding(.bottom, 8) + + // 완료 문구 + Text("결제가 완료되었습니다") + .font(.title3) + .bold() + Text("주문 내역은 발주 탭에서 확인할 수 있습니다.") + .font(.subheadline) + .foregroundColor(.gray) + + Spacer() + + // 하단 버튼 + VStack(spacing: 12) { + Button { + dismiss() // 이전 화면으로 돌아가기 + } label: { + Text("확인") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .foregroundColor(.white) + .background(Color.blue) + .cornerRadius(12) + } + + Button { + // 홈으로 이동 (추후 NavigationStack 연결 시) + } label: { + Text("홈으로 이동") + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .foregroundColor(.blue) + .background(Color.white) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.blue, lineWidth: 1) + ) + } + } + .padding(.horizontal) + + Spacer() + } + .padding() + .navigationBarBackButtonHidden(true) + .navigationTitle("결제 완료") + } +} + +#Preview { + OrderResultView() +} diff --git a/StockMate/StockMate/app/feature/orders/ui/OrderView.swift b/StockMate/StockMate/app/feature/orders/ui/OrderView.swift new file mode 100644 index 0000000..8334011 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/OrderView.swift @@ -0,0 +1,108 @@ +// +// OrderView.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import SwiftUI + +struct OrderView: View { + @StateObject var inventoryViewModel = InventoryViewModel() + @ObservedObject var cartViewModel: CartViewModel + + var body: some View { + ZStack{ + ScrollView { + // 타이틀 + Text("발주 요청") + .font(.title2) + .bold() + .padding(.top, 13) + .padding(.leading, 25) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("직접 발주") + .font(.system(size: 14)) + .bold() + .padding(.top, 13) + .padding(.leading, 25) + .frame(maxWidth: .infinity, alignment: .leading) + + // 검색창 + NavigationLink(destination: + OrderRequestSearchView(cartViewModel: cartViewModel) + ) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + + Text("부품을 검색하세요.") + .foregroundColor(.gray) + Spacer() + } + .padding() + .background(Color(.white)) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(Color.GrayMordern400, lineWidth: 1) + ) + .padding(.horizontal) + } + .buttonStyle(.plain) + + // 타이틀 + Text("부족 재고") + .font(.system(size: 14)) + .bold() + .padding(.top, 13) + .padding(.leading, 25) + .frame(maxWidth: .infinity, alignment: .leading) + + LazyVStack(alignment: .leading, spacing: 14) { + ForEach(inventoryViewModel.underLimitItems) { item in + + let qty = cartViewModel.quantity(for: item.id) + + OrderRequestCardView( + item: item, + quantity: qty, + onIncrease: { Task { await cartViewModel.increaseQuantity(for: item.id) } }, + onDecrease: { Task { await cartViewModel.decreaseQuantity(for: item.id) } }, + onAddToCart: { Task { await cartViewModel.addToCart(partId: item.id, amount: 1) } } + ) + .onAppear { + if item.id == inventoryViewModel.underLimitItems.last?.id { + Task { await inventoryViewModel.loadUnderLimitList() } + } + } + } + + if inventoryViewModel.isLoading { + ProgressView().padding() + } + } + .padding(.horizontal) + + } + .background(Color.Light) + .task { + if inventoryViewModel.underLimitItems.isEmpty { + await inventoryViewModel.loadUnderLimitList(reset: true) + } + await cartViewModel.fetchCart() + } + + // OrderView 내부 ScrollView 아래 장바구니 확인 버튼 + VStack { + Spacer() + CartSummaryBar(cartVM: cartViewModel) + } + .ignoresSafeArea(edges: .bottom) + + } + + } +} + diff --git a/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift b/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift new file mode 100644 index 0000000..e0b953b --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/ui/ReceiptView.swift @@ -0,0 +1,271 @@ +// +// ReceiptView.swift +// StockMate +// +// Created by Admin on 10/28/25. +// + +import SwiftUI +import PDFKit +import UIKit + +enum PDFType { + case a4 + case receipt80mm +} + +struct ReceiptView: View { + @Environment(\.dismiss) private var dismiss + let orderId: Int + + @StateObject private var detailViewModel = OrderDetailViewModel() + + @State var sellerName = "홍길동" + @State var businessNumber = "215-87-12345" + @State var phone = "02-567-8901" + @State var address = "서울특별시 금천구 가산동 459-9" + + + var body: some View { + ScrollView { + if detailViewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let order = detailViewModel.order { + VStack(alignment: .leading) { + receiptContent(order: order) + } + .background(Color.white) + .cornerRadius(12) + .padding(.horizontal) + .padding(.top) + .padding(.bottom,4) + + Button { + generatePDF(type: .receipt80mm, order: order) + } label: { + Text("PDF 저장") + .font(.system(size: 13, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 8) + .background(Color.Primary) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.horizontal) + .padding(.bottom) + } + } + .background(Color.Light) + .navigationTitle("영수증") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .task { + await detailViewModel.fetchOrderDetail(orderId: orderId) + } + + } + + private func receiptContent(order: OrderResponseItem) -> some View { + let total = order.totalPrice + let vat = Int(Double(total) * 10 / 110) // 부가세액 + let supplyPrice = total - vat // 공급가액 + + return VStack(alignment: .leading, spacing: 16) { + section("결제 정보") { + Divider() + if order.paymentType == "DEPOSIT" { + row("거래종류", "예치금") + } else { + row("결제수단", "신용카드") + } + row("승인번호", formattedApprovalNumber(order.createdAt)) + row("거래일시", formattedDate(order.createdAt)) + + } + .padding(4) + .padding(.top,5) + + section("구매정보") { + Divider() + VStack(spacing: 8){ + row("주문번호", order.orderNumber) + VStack{ + // 상품명 라벨과 첫 번째 상품 같은 라인 + if let first = order.orderItems.first { + HStack { + Text("상품명") + Spacer() + Text("\(first.partDetail.korName) \(first.amount)개") + } + } + // 나머지는 label 없이 아래에 + ForEach(order.orderItems.dropFirst(), id: \.partId) { item in + HStack { + Spacer() // label 영역만큼 들여쓰기 효과 + Text("\(item.partDetail.korName) \(item.amount)개") + } + } + } + row("공급가액", "\(formatPrice(supplyPrice))원") + row("부가세액", "\(formatPrice(vat))원") + row("합계금액", "\(formatPrice(total))원", highlight: true) + } + } + .padding(4) + .padding(.top) + + section("판매자 정보") { + Divider() + row("대표자명", sellerName) + row("사업자등록번호", businessNumber) + row("전화번호", phone) + row("사업장주소", address) + } + .padding(4) + .padding(.top) + + Divider() + + NoticeTextView() + .padding(.top, 4) + + } + .padding() + } + + private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + content() + } + .font(.subheadline) + } + + private func row(_ key: String, _ value: String, highlight: Bool = false) -> some View { + HStack { + Text(key) + Spacer() + Text(value) + .fontWeight(highlight ? .bold : .regular) + .foregroundColor(highlight ? .Primary : .primary) + } + } + + private func generatePDF(type: PDFType, order: OrderResponseItem) { + // 아이폰 화면 비율로 렌더링 (디바이스 폭 고정) + let screenWidth = UIScreen.main.bounds.width + let view = receiptContent(order: order) + .frame(width: screenWidth) // 폭 고정 (문장 길이에 따라 늘어나지 않음) + .background(Color.white) + + let renderer = ImageRenderer(content: view) + renderer.scale = UIScreen.main.scale + + if let cgImage = renderer.cgImage { + let uiImage = UIImage(cgImage: cgImage) + let pdfDoc = PDFDocument() + if let pdfPage = PDFPage(image: uiImage) { + pdfDoc.insert(pdfPage, at: 0) + } + + // 파일명: 주문번호 기반 + let fileName = "receipt_\(order.orderNumber).pdf" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + if pdfDoc.write(to: tempURL) { + let av = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + av.popoverPresentationController?.sourceView = rootVC.view + rootVC.present(av, animated: true) + } + } + } + } +} + +func formattedDate(_ timestamp: String) -> String { + let inputFormatter = DateFormatter() + inputFormatter.locale = Locale(identifier: "ko_KR") + inputFormatter.timeZone = TimeZone.current + 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 formattedApprovalNumber(_ 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 = "yyyyMMddHHmm" + + return outputFormatter.string(from: date) +} + +struct NoticeTextView: View { + let notices = [ + "본 영수증은 거래완료 후 국세청 반영까지 시간이 소요될 수 있습니다.", + "현금영수증/지출증빙 여부는 국세청 홈페이지 또는 상담센터(126)에서 확인하세요.", + "비현금성으로 지급되는 포인트로 결제한 금액은 현금영수증 발행 대상에서 제외될 수 있습니다.", + "발행 방법이 자진 발급인 경우 국세청 사이트에서 자진발급분을 사용자 등록 후 소득공제 등 혜택을 받으실 수 있습니다.", + "발행 정보는 구매확정 또는 거래 완료 후 전달되며, 국세청 사이트에 즉시 반영되지 않을 수 있습니다.", + "이 영수증은 조세특례제한법 제126조 3항에 의거, 연말정산 시 소득공제 혜택 부여 목적 등으로 발행됩니다. (국세청 회원가입 필요)", + "현금 영수증은 구매 확정 또는 거래 완료 후 48시간 내에 국세청에서 확인 작업 후 최종 확정됩니다.", + "국세청 확인: 홈택스 홈페이지(https://www.hometax.go.kr/) 또는 국세청 상담센터(현금영수증 문의 ☎️126-1-1)." + ] + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(notices, id: \.self) { text in + HStack(alignment: .top, spacing: 3) { + Text("•") + .font(.system(size: 11)) + .foregroundColor(.gray) + Text(text) + .font(.system(size: 11.5)) + .foregroundColor(.textGray1) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + } +} diff --git a/StockMate/StockMate/app/feature/orders/viewmodel/OrderDetailViewModel.swift b/StockMate/StockMate/app/feature/orders/viewmodel/OrderDetailViewModel.swift new file mode 100644 index 0000000..2903e8c --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/viewmodel/OrderDetailViewModel.swift @@ -0,0 +1,31 @@ +// +// OrderDetailViewModel.swift +// StockMate +// +// Created by Admin on 10/22/25. +// + +import Foundation + +@MainActor +final class OrderDetailViewModel: ObservableObject { + @Published var order: OrderResponseItem? + @Published var isLoading = false + @Published var errorMessage: String? + + private let repository = OrderRepositoryImpl() + + func fetchOrderDetail(orderId: Int) async { + isLoading = true + defer { isLoading = false } + + let result = await repository.fetchOrderDetail(orderId: orderId) + + switch result { + case .success(let detail): + self.order = detail + case .failure(let error): + self.errorMessage = error.localizedDescription + } + } +} diff --git a/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift b/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift new file mode 100644 index 0000000..ddb87c9 --- /dev/null +++ b/StockMate/StockMate/app/feature/orders/viewmodel/OrderViewModel.swift @@ -0,0 +1,99 @@ +// +// OrderViewModel.swift +// StockMate +// +// Created by Admin on 10/21/25. +// + +import Foundation + +@MainActor +final class OrderViewModel: ObservableObject { + @Published var orders: [OrderResponseItem] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + @Published var isOrderSuccess: Bool = false + @Published var isOrderCanceled: Bool = false + + @Published var createdOrderId: Int? + + private let repository: OrderRepositoryProtocol + + init(repository: OrderRepositoryProtocol = OrderRepositoryImpl()) { + self.repository = repository + } + // MARK: - 주문 목록 조회 + func loadOrders( + status: String? = nil, + startDate: String? = nil, + endDate: String? = nil, + page: Int = 0, + size: Int = 20 + ) async { + isLoading = true + defer { isLoading = false } + + let result = await repository.fetchMyOrders( + status: status, + startDate: startDate, + endDate: endDate, + page: page, + size: size + ) + + switch result { + case .success(let pageData): + orders = pageData.content + case .failure(let error): + errorMessage = error.message + } + } + + // MARK: - 주문 생성 + func createOrder(request: OrderRequest) async { + let result = await repository.createOrder(request: request) + + switch result { + case .success(let response): + self.createdOrderId = response.orderId + self.isOrderSuccess = true + + case .failure(let error): + print("주문 실패:", error.message) + self.errorMessage = error.message + } + } + + + // MARK: - 주문 취소 + func cancelOrder(orderId: Int) async { + isLoading = true + let result = await repository.cancelOrder(orderId: orderId) + + switch result { + case .success: + await loadOrders() // 취소 후 즉시 UI 새로고침 + case .failure(let error): + errorMessage = error.message + } + isLoading = false + } + + // MARK: - 입고 처리 (주문 수령) + 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) + } + } +} 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..9df2b88 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartApi.swift @@ -0,0 +1,77 @@ +// +// PartApi.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + + +import Foundation +import Alamofire + +// 출고(Release) 요청 시 사용되는 부품 항목 데이터 모델 +struct ReleaseItemRequest: Encodable { + let partId: Int + let quantity: Int +} + +// 부품 상세 정보 응답 +struct PartDetailResponse: 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 +} + +// 사용처리 임시 값 +struct PartDetail: Identifiable { + 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 { + static func releaseParts(items: [ReleaseItemRequest]) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/store/release" + let body: [String: Any] = [ + "items": items.map { ["partId": $0.partId, "quantity": $0.quantity] } + + ] + return ApiClient.shared.request( + url, + method: .post, + parameters: body, + encoding: JSONEncoding.default + ) + } + + // 부품 상세 조회 API + 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 new file mode 100644 index 0000000..87da8ce --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartRepositoryImpl.swift @@ -0,0 +1,24 @@ +// +// 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) + } + + // 부품 상세 정보 조회 + 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..11f7fb2 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/data/PartStore.swift @@ -0,0 +1,59 @@ +// +// 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 }) { + parts[index].quantity += 1 // 이미 존재하면 수량만 +1 + } else { + var newPart = part + newPart.quantity = 1 + parts.append(newPart) + } + } + + // 선택된 부품 전체 초기화 + func clear() { + parts.removeAll() + } + + // 수량 변경용 메서드 추가 + func increaseQuantity(for part: PartDetail) { + if let index = parts.firstIndex(where: { $0.id == part.id }) { + parts[index].quantity += 1 + objectWillChange.send() // 수동 갱신 트리거 + } + } + + // 부품 수량 감소 (최소 1개까지) + 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() + } + } + + // 수량 감소 후 1개 이하일 경우 목록에서 제거 + 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/domain/PartRepositoryProtocol.swift b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift new file mode 100644 index 0000000..d75befa --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/domain/PartRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// PartRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import Foundation + +protocol PartRepositoryProtocol { + // 부품 출고 요청 + func releaseParts(items: [ReleaseItemRequest]) 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 new file mode 100644 index 0000000..4773356 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerView.swift @@ -0,0 +1,43 @@ +// +// QRScannerView.swift +// StockMate +// +// Created by Admin on 10/31/25. +// + +import SwiftUI + +struct QRScannerView: UIViewControllerRepresentable { + @Binding var scannedCode: String? + var isActive: Bool = true + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) { + if isActive { + uiViewController.startSession() // 바텀시트 닫혔을 때 다시 스캔 시작 + } else { + uiViewController.stopSession() // 바텀시트 열렸을 때 스캔 일시정지 + } + } + + 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..0ec635f --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/ui/QRScannerViewController.swift @@ -0,0 +1,97 @@ +// +// 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) + + + startScanning() + } + + // 스캔 재시작/중단 함수 추가 + 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() + } + } + + 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, + 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..d4ce883 --- /dev/null +++ b/StockMate/StockMate/app/feature/parts/viewmodel/PartViewModel.swift @@ -0,0 +1,71 @@ +// +// 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 + + + @Published var partDetails: [PartDetailResponse] = [] + + @Published var quantities: [Int: Int] = [:] // partId별 수량 관리 + + 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) + } + } + + func fetchPartDetail(partIds: Int) async { + isLoading = true + defer { isLoading = false } + + let result = await repo.fetchPartDetail(partIds: [partIds]) + 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/feature/payment/data/PaymentApi.swift b/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift new file mode 100644 index 0000000..fcca9b3 --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/data/PaymentApi.swift @@ -0,0 +1,66 @@ +// +// PaymentApi.swift +// StockMate +// +// Created by Admin on 10/29/25. +// + + +import Foundation +import Alamofire + + +struct PaymentAmount: Decodable { + let id: Int + let balance: Int + let userId: Int +} + +// 월별 소비 내역 구조체 +struct MonthlySpending: Decodable, Identifiable { + var id: String { month } // 리스트에서 사용하기 편하게 + let month: String + let totalAmount: Int +} + +struct CategorySpending: Decodable, Identifiable { + var id: String { categoryName } + let categoryName: String + let totalAmount: Int +} + + +enum PaymentApi { + // 예치금 조회 + static func getPaymentAmount() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/payment/amount" + return ApiClient.shared.request(url, method: .get) + } + + // 예치금 충전 + static func chargeDeposit(amount: Int) -> DataRequest { + let url = ApiClient.baseURL + "api/v1/payment/charge" + let params: [String: Any] = [ + "amount": amount + ] + return ApiClient.shared.request( + url, + method: .post, + parameters: params, + encoding: URLEncoding.queryString + ) + } + + // 최근 5개월 소비 내역 조회 + static func getMonthlySpending() -> DataRequest { + 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 new file mode 100644 index 0000000..eee8a37 --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/data/PaymentRepositoryImpl.swift @@ -0,0 +1,84 @@ +// +// PaymentRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/29/25. +// + + +import Foundation +import Alamofire + + +// 결제 및 예치금 관련 Repository 구현체 +final class PaymentRepositoryImpl: PaymentRepositoryProtocol { + + // 예치금 잔액 조회 + func fetchDepositAmount() async -> AppResult { + let request = PaymentApi.getPaymentAmount() + let result = await safeApi(request, decodeTo: ApiResponse.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) + } + } + + // 예치금 충전 + func chargeDeposit(amount: Int) async -> AppResult { + let request = PaymentApi.chargeDeposit(amount: amount) + let result = await safeApi(request, decodeTo: ApiResponse.self) + + switch result { + case .success(let response): + return .success(response.data ?? response.message) + + case .failure(let error): + 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) + } + } + + // 지난달 카테고리별 지출 + 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 new file mode 100644 index 0000000..bc5ffbc --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/domain/PaymentRepositoryProtocol.swift @@ -0,0 +1,21 @@ +// +// PaymentRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/29/25. +// + +import Foundation +import Alamofire + +protocol PaymentRepositoryProtocol { + func fetchDepositAmount() async -> AppResult + func chargeDeposit(amount: Int) async -> AppResult + + // 최근 5개월 소비 내역 조회 + func fetchMonthlySpending() async -> AppResult<[MonthlySpending]> + + // 지난달 카테고리별 지출 + func fetchCategorySpending() async -> AppResult<[CategorySpending]> + +} diff --git a/StockMate/StockMate/app/feature/payment/ui/DepositChargeView.swift b/StockMate/StockMate/app/feature/payment/ui/DepositChargeView.swift new file mode 100644 index 0000000..5070451 --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/ui/DepositChargeView.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct DepositChargeView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var viewModel: DepositViewModel + @State private var amountText: String = "" + @State private var isCharging: Bool = false + + var onChargeSuccess: (() -> Void)? + + + let keypad: [[String]] = [ + ["1","2","3"], + ["4","5","6"], + ["7","8","9"], + ["00","0","⌫"] + ] + + private var formattedNumberString: String { + if let value = Int(amountText) { + return value.formatted(.number) + } + return "0" + } + + private var formattedAmount: String { + return formattedNumberString + "원" + } + + func buttonAction(_ val: String) { + if val == "⌫" { + if !amountText.isEmpty { amountText.removeLast() } + } else { + amountText.append(val) + } + } + + var body: some View { + VStack(spacing: 20) { + + // Title + Text("예치금 충전") + .font(.system(size: 18, weight: .semibold)) + .padding(.top, 42) + + // 금액 + Text(formattedAmount) + .font(.system(size: 32, weight: .bold)) + + // 키패드 + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 11), count: 3), + spacing: 16) { + ForEach(keypad.flatMap { $0 }, id: \.self) { key in + Button { + buttonAction(key) + } label: { + if key == "⌫" { + Image("backspace") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .frame(width: 60, height: 45) + .background(Color.white) + } else { + Text(key) + .font(.system(size: 19)) + .foregroundColor(Color.black) + .frame(width: 60, height: 45) + .background(Color.white) + } + } + } + } + .padding(.horizontal,16) + + + // 충전 버튼 + Button { + guard !isCharging else { return } + Task { + guard let amount = Int(amountText), amount > 0 else { return } + isCharging = true + let success = await viewModel.chargeDeposit(amount: amount) + isCharging = false + if success { + viewModel.showChargeSheet = false + dismiss() + onChargeSuccess?() + } else { + + } + } + } label: { + if isCharging { + ProgressView() + .tint(.white) + .frame(height: 56) + .frame(maxWidth: .infinity) + } else { + Text("충전") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(height: 49) + .frame(maxWidth: .infinity) + } + } + .background( + (Int(amountText) ?? 0) > 0 && !isCharging + ? Color.Primary + : Color.gray.opacity(0.3) + ) + .cornerRadius(18) + .padding(.bottom, 25) + .padding(.horizontal, 10) + .disabled(isCharging || (Int(amountText) ?? 0) == 0) + } + .padding(.horizontal, 20) + .background(Color.white) + + + } +} 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..07b2b94 --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/viewmodel/DashboardViewModel.swift @@ -0,0 +1,69 @@ +// +// 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 categorySpendings: [CategorySpending] = [] + + @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) + } + } + + // 지난달 카테고리별 지출 금액 조회 + 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 [] } + 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 { + 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/app/feature/payment/viewmodel/DepositViewModel.swift b/StockMate/StockMate/app/feature/payment/viewmodel/DepositViewModel.swift new file mode 100644 index 0000000..3eaad6e --- /dev/null +++ b/StockMate/StockMate/app/feature/payment/viewmodel/DepositViewModel.swift @@ -0,0 +1,50 @@ +// +// PaymentViewModel.swift +// StockMate +// +// Created by Admin on 10/29/25. +// + +import Foundation + +@MainActor +final class DepositViewModel: ObservableObject { + private let repository: PaymentRepositoryProtocol = PaymentRepositoryImpl() + + @Published var balance: Int = 0 + @Published var isLoading: Bool = false + @Published var showChargeSheet: Bool = false + + @Published var depositAmount: Int = 0 + @Published var isChargeSuccess: Bool = false + + // 예치금 조회 + func fetchDepositAmount() async { + isLoading = true + + let result = await repository.fetchDepositAmount() + + isLoading = false + + switch result { + case .success(let data): + self.balance = data.balance + case .failure(let error): + print("❌ 예치금 조회 실패:", error.message) + } + } + + // 예치금 충전 + func chargeDeposit(amount: Int) async -> Bool { + let result = await repository.chargeDeposit(amount: amount) + switch result { + case .success(_): + await fetchDepositAmount() + isChargeSuccess = true + return true + case .failure(let error): + print("❌ 충전 실패:", error.message) + return false + } + } +} diff --git a/StockMate/StockMate/app/feature/user/data/UserApi.swift b/StockMate/StockMate/app/feature/user/data/UserApi.swift new file mode 100644 index 0000000..5a6970a --- /dev/null +++ b/StockMate/StockMate/app/feature/user/data/UserApi.swift @@ -0,0 +1,42 @@ +// +// UserApi.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import Foundation +import Alamofire + +// MARK: - 사용자 정보 응답 모델 +struct UserInfoResponse: Decodable { + let status: Int + let success: Bool + let message: String + let data: UserInfo? +} + +// MARK: - 사용자 정보 모델 +struct UserInfo: Decodable { + let createdAt: String + let updatedAt: String + let id: Int + let memberId: Int + let email: String + let owner: String + let address: String + let storeName: String + let businessNumber: String + let latitude: Double + let longitude: Double + let role: String + let verified: String +} + +// MARK: - 사용자 API 요청 정의 +enum UserApi { + static func getUserInfo() -> DataRequest { + let url = ApiClient.baseURL + "api/v1/user/my" + return ApiClient.shared.request(url, method: .get) + } +} diff --git a/StockMate/StockMate/app/feature/user/data/UserRepositoryImpl.swift b/StockMate/StockMate/app/feature/user/data/UserRepositoryImpl.swift new file mode 100644 index 0000000..69caa17 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/data/UserRepositoryImpl.swift @@ -0,0 +1,18 @@ +// +// UserRepositoryImpl.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import Foundation +import Alamofire + +// MARK: - 사용자 관련 Repository 구현체 +final class UserRepositoryImpl: UserRepositoryProtocol { + // 사용자 정보 조회 + func getUserInfo() async -> AppResult> { + let dataReq = UserApi.getUserInfo() + return await safeApi(dataReq, decodeTo: ApiResponse.self) + } +} diff --git a/StockMate/StockMate/app/feature/user/domain/UserRepositoryProtocol.swift b/StockMate/StockMate/app/feature/user/domain/UserRepositoryProtocol.swift new file mode 100644 index 0000000..460f943 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/domain/UserRepositoryProtocol.swift @@ -0,0 +1,13 @@ +// +// UserRepositoryProtocol.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import Foundation + +protocol UserRepositoryProtocol { + // 사용자 정보 조회 + func getUserInfo() async -> AppResult> +} diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift new file mode 100644 index 0000000..3f26da6 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/ui/ProfileCircleView.swift @@ -0,0 +1,36 @@ +// +// ProfileCircleView.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import SwiftUI + +struct ProfileCircleView: View { + let name: String + let size: CGFloat + + private var initials: String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let firstTwo = trimmed.prefix(2) + return String(firstTwo) + } + + var body: some View { + 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) + } +} + +#Preview { + ProfileCircleView(name: "유현아", size: 50) +} diff --git a/StockMate/StockMate/app/feature/user/ui/ProfileView.swift b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift new file mode 100644 index 0000000..fc1edc6 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/ui/ProfileView.swift @@ -0,0 +1,160 @@ +// +// ProfileView().swift +// StockMate +// +// Created by Admin on 10/21/25. +// + +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 + HStack(spacing: 16) { + ProfileCircleView(name: userViewModel.userInfo?.owner ?? "사용자", size: 50) + + VStack(alignment: .leading, spacing: 4) { + Text(userViewModel.userInfo?.owner ?? "이름 없음") + .font(.title3.bold()) + .foregroundColor(Color(hex: "#2B3A1A")) + + Text(userViewModel.userInfo?.email ?? "이메일 없음") + .foregroundColor(.gray) + .font(.subheadline) + } + + Spacer() + } + .padding(.horizontal) + .padding(.top, 32) + + // MARK: - General Section + VStack(alignment: .leading, spacing: 12) { + VStack(spacing: 10) { + SettingNavigationRow(icon: "user", title: "프로필 확인", destination: UserProfileView()) + SettingNavigationRow(icon: "notification", title: "알림", destination: NotificationListView()) + SettingNavigationRow(icon: "receipt", title: "예치금 히스토리", destination: TransactionTypeListView()) + SettingNavigationRow(icon: "bag", title: "주문 내역", destination: OrderListView()) + // 로그아웃 버튼 + Button { + showLogoutModal = true + } label: { + SettingRow(icon: "logout", title: "로그아웃") + } + } + .padding(3) + .background(Color.Light) + .cornerRadius(12) + .padding(.horizontal) + } + + Spacer() + } + .background(Color.Light) + .navigationBarTitleDisplayMode(.inline) + .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) + } + } + + // MARK: - SettingRow (동일) +struct SettingRow: View { + var icon: String + var title: String + var iconColor: Color = .black + var textColor: Color = .primary + + var body: some View { + HStack { + Image(icon) + .font(.system(size: 18)) + .foregroundColor(iconColor) + .frame(width: 24) + + Text(title) + .font(.system(size: 16)) + .foregroundColor(textColor) + + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.Light)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(.systemGray4), lineWidth: 1)) + .shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1) + } +} + +struct SettingNavigationRow: View { + var icon: String + var title: String + var iconColor: Color = .black + var textColor: Color = .primary + var destination: Destination + + var body: some View { + NavigationLink(destination: destination) { + HStack { + Image(icon) + .font(.system(size: 18)) + .foregroundColor(iconColor) + .frame(width: 24) + + Text(title) + .font(.system(size: 16)) + .foregroundColor(textColor) + + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.gray) + } + .padding() + .background(RoundedRectangle(cornerRadius: 10).fill(Color.Light)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(.systemGray4), lineWidth: 1)) + .shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1) + } + } +} + + + #Preview { + ProfileView() + } diff --git a/StockMate/StockMate/app/feature/user/ui/UserInfoView.swift b/StockMate/StockMate/app/feature/user/ui/UserInfoView.swift new file mode 100644 index 0000000..2410fb9 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/ui/UserInfoView.swift @@ -0,0 +1,34 @@ +// +// UserInfoView.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import SwiftUI + +struct UserInfoView: View { + @StateObject private var viewModel = UserViewModel() + + var body: some View { + VStack(spacing: 12) { + if let info = viewModel.userInfo { + Text("이메일: \(info.email)") + Text("가게명: \(info.storeName)") + Text("주소: \(info.address)") + Text("사업자번호: \(info.businessNumber)") + Text("권한: \(info.role)") + } else if !viewModel.message.isEmpty { + Text(viewModel.message).foregroundColor(.red) + } else { + ProgressView("유저 정보 불러오는 중...") + } + } + .padding() + .onAppear { + Task { + await viewModel.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..0ce5291 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/ui/UserProfileView.swift @@ -0,0 +1,62 @@ +// +// UserProfileView.swift +// StockMate +// +// Created by Admin on 11/5/25. +// + +import SwiftUI + +struct UserProfileView: View { + @Environment(\.dismiss) private var dismiss + @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) { + 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() + + } + .padding(3) + .cornerRadius(12) + .padding(.horizontal) + } + .navigationTitle("프로필 확인") + .background(Color.Light) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.left") + .font(.system(size: 15, weight: .medium)) + } + .foregroundColor(.black) + } + } + } + .onAppear { + Task { await userViewModel.loadUserInfo() } + } + } +} + +#Preview { + UserProfileView() +} diff --git a/StockMate/StockMate/app/feature/user/viewmodel/UserViewModel.swift b/StockMate/StockMate/app/feature/user/viewmodel/UserViewModel.swift new file mode 100644 index 0000000..c683087 --- /dev/null +++ b/StockMate/StockMate/app/feature/user/viewmodel/UserViewModel.swift @@ -0,0 +1,43 @@ +// +// UserViewModel.swift +// StockMate +// +// Created by Admin on 10/14/25. +// + +import SwiftUI + +@MainActor +final class UserViewModel: ObservableObject { + @Published var userInfo: UserInfo? + @Published var message: String = "" + @Published var shouldGoToLogin: Bool = false + + private let repo: UserRepositoryProtocol + + init(repo: UserRepositoryProtocol = UserRepositoryImpl()) { + self.repo = repo + } + + func loadUserInfo() async { + let result = await repo.getUserInfo() + switch result { + case .success(let apiResp): + if let info = apiResp.data { + userInfo = info + print("유저 정보 불러오기 성공:", info) + } else { + message = apiResp.message + print("데이터 없음:", apiResp.message) + } + case .failure(let err): + message = err.message + print("유저 정보 불러오기 실패:", err.message) + + if err.code == 401 || err.code == 403 { // 세션 만료나 인증 문제면 로그인 화면으로 유도 + shouldGoToLogin = true + } + + } + } +} diff --git a/StockMate/StockMate/app/navigation/AppNavHost.swift b/StockMate/StockMate/app/navigation/AppNavHost.swift new file mode 100644 index 0000000..f95ea21 --- /dev/null +++ b/StockMate/StockMate/app/navigation/AppNavHost.swift @@ -0,0 +1,36 @@ +// +// AppNavHost.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import SwiftUI + +// 앱의 전반적인 네비게이션 흐름을 관리하는 뷰 +struct AppNavHost: View { + @EnvironmentObject var authViewModel: AuthViewModel // 인증 상태 관리 뷰모델 + @StateObject private var partStore = PartStore() // 부품 관련 상태 저장소 + + + var body: some View { + NavigationStack { + switch authViewModel.authState { + case .unauthenticated: + // 로그인되지 않은 경우 → 로그인 화면 표시 + LoginView(onClickRegister: { + authViewModel.goToRegister() + }) + case .registering: + // 회원가입 중인 경우 → 회원가입 화면 표시 + RegisterView() + case .authenticated: + // 로그인 완료된 경우 → 메인 탭 화면 표시 + MainTabView() + .environmentObject(authViewModel) + .environmentObject(partStore) + } + } + } +} + diff --git a/StockMate/StockMate/app/navigation/IntroView.swift b/StockMate/StockMate/app/navigation/IntroView.swift new file mode 100644 index 0000000..742d03a --- /dev/null +++ b/StockMate/StockMate/app/navigation/IntroView.swift @@ -0,0 +1,21 @@ +// +// IntroView.swift +// StockMate +// +// Created by Admin on 11/10/25. +// + +import SwiftUI + +struct IntroView: View { + var body: some View { + ZStack { + Color.white.ignoresSafeArea() // 배경 흰색 + + Image("intrologo") // Assets에 있는 이미지 이름 + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) // 필요시 크기 조정 + } + } +} diff --git a/StockMate/StockMate/app/navigation/MainTabView.swift b/StockMate/StockMate/app/navigation/MainTabView.swift new file mode 100644 index 0000000..ec1ad2a --- /dev/null +++ b/StockMate/StockMate/app/navigation/MainTabView.swift @@ -0,0 +1,75 @@ +// +// MainTabView.swift +// StockMate +// +// Created by Admin on 10/7/25. +// + +import SwiftUI + +struct MainTabView: View { + @StateObject var cartVM = CartViewModel() + + @State private var selectedTab = 0 + @State private var tabTappedTrigger = false + + var body: some View { + VStack(spacing: 0) { + // 메인 화면 + ZStack { + switch selectedTab { + case 0: HomeView() + case 1: NavigationStack{ OrderView(cartViewModel: cartVM) } + case 2: + InventoryView( + selectedTab: $selectedTab, + tabTappedTrigger: $tabTappedTrigger + ) + case 3: ProfileView() + default: NavigationStack{ ContentView() } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // 커스텀 탭바 + HStack { + tabButton(index: 0, icon: "tabHome", text: "홈") + tabButton(index: 1, icon: "tabPackage", text: "발주") + tabButton(index: 2, icon: "tabInventory", text: "재고관리") + tabButton(index: 3, icon: "tabProfile", text: "사용자") + } + .padding(.vertical, 24) + .padding(.horizontal, 20) + .background(Color.White) + } + .edgesIgnoringSafeArea(.bottom) + } + + // 커스텀 탭 버튼 + func tabButton(index: Int, icon: String, text: String) -> some View { + let isSelected = selectedTab == index + return Button { + if selectedTab == index { + // 같은 탭 다시 누르면 트리거 토글 + tabTappedTrigger.toggle() + } else { + withAnimation(.easeInOut) { + selectedTab = index + } + } + } label: { + VStack(spacing: 6) { + Image(icon) + .renderingMode(.template) + .scaledToFit() + .frame(height: 20) + .foregroundColor(isSelected ? Color.Primary : Color.textGray2) + + Text(text) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(isSelected ? Color.Primary : Color.textGray2) + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/AppIcon_1024.png b/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/AppIcon_1024.png new file mode 100644 index 0000000..de54277 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/AppIcon_1024.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..840dd3d 100644 --- a/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/StockMate/StockMate/resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/Contents.json new file mode 100644 index 0000000..cd9ccd9 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "InvIncoming@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "InvIncoming@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "InvIncoming@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@1x.png b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@1x.png new file mode 100644 index 0000000..3555741 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@2x.png b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@2x.png new file mode 100644 index 0000000..550dfd6 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@3x.png b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@3x.png new file mode 100644 index 0000000..99fa468 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvIncoming.imageset/InvIncoming@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/Contents.json new file mode 100644 index 0000000..9f16915 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "InvStock@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "InvStock@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "InvStock@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@1x.png b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@1x.png new file mode 100644 index 0000000..01d9c86 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@2x.png b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@2x.png new file mode 100644 index 0000000..9f76217 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@3x.png b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@3x.png new file mode 100644 index 0000000..bb4c989 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvStock.imageset/InvStock@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/Contents.json new file mode 100644 index 0000000..7c77bf4 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "InvTrans@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "InvTrans@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "InvTrans@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@1x.png b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@1x.png new file mode 100644 index 0000000..0bc5d85 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@2x.png b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@2x.png new file mode 100644 index 0000000..3ca0a43 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@3x.png b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@3x.png new file mode 100644 index 0000000..4c743a4 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvTrans.imageset/InvTrans@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/Contents.json new file mode 100644 index 0000000..d5b4888 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "InvUse@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "InvUse@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "InvUse@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@1x.png b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@1x.png new file mode 100644 index 0000000..83b17f5 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@2x.png b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@2x.png new file mode 100644 index 0000000..429fcbc Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@3x.png b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@3x.png new file mode 100644 index 0000000..0b99e88 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/InvUse.imageset/InvUse@3x.png differ 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 0000000..a9d1e53 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@2x.png b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@2x.png new file mode 100644 index 0000000..c10a2d1 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@3x.png b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@3x.png new file mode 100644 index 0000000..083f568 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/add_shopping_cart.imageset/add_shopping_cart@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/Contents.json new file mode 100644 index 0000000..b693e6f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "backspace@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "backspace@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "backspace@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@1x.png b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@1x.png new file mode 100644 index 0000000..1aff5a9 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@2x.png b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@2x.png new file mode 100644 index 0000000..84f1648 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@3x.png b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@3x.png new file mode 100644 index 0000000..f7b505b Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/backspace.imageset/backspace@3x.png differ 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..0ce9f32 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/bag.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "bag.svg", + "idiom" : "universal" + } + ], + "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/cal.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/Contents.json new file mode 100644 index 0000000..77d19ae --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "cal@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cal@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "cal@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@1x.png b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@1x.png new file mode 100644 index 0000000..e1c01f1 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@2x.png b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@2x.png new file mode 100644 index 0000000..0ea001a Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@3x.png b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@3x.png new file mode 100644 index 0000000..4f98e83 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/cal.imageset/cal@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/Contents.json new file mode 100644 index 0000000..052b5da --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "chair.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/chair.svg b/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/chair.svg new file mode 100644 index 0000000..ea15a1f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/chair.imageset/chair.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/check.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/check.imageset/Contents.json new file mode 100644 index 0000000..b09981a --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/check.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "check.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/check.imageset/check.svg b/StockMate/StockMate/resources/Assets.xcassets/check.imageset/check.svg new file mode 100644 index 0000000..8bdc85f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/check.imageset/check.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/Contents.json new file mode 100644 index 0000000..bd8698d --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "cog.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/cog.svg b/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/cog.svg new file mode 100644 index 0000000..bb97be6 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/cog.imageset/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/Contents.json new file mode 100644 index 0000000..77174c8 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "defect@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "defect@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "defect@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@1x.png b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@1x.png new file mode 100644 index 0000000..493ce57 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@2x.png b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@2x.png new file mode 100644 index 0000000..c2bdafe Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@3x.png b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@3x.png new file mode 100644 index 0000000..518e259 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/defect.imageset/defect@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/Contents.json new file mode 100644 index 0000000..7c4374c --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "deposit_background@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "deposit_background@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "deposit_background@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@1x.png b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@1x.png new file mode 100644 index 0000000..b988925 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@2x.png b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@2x.png new file mode 100644 index 0000000..e94ecc4 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@3x.png b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@3x.png new file mode 100644 index 0000000..84218d3 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/deposit_background.imageset/deposit_background@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/Contents.json new file mode 100644 index 0000000..30d1dee --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "dline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/dline.svg b/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/dline.svg new file mode 100644 index 0000000..b75984c --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/dline.imageset/dline.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/Contents.json new file mode 100644 index 0000000..3551fde --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "dottedline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/dottedline.svg b/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/dottedline.svg new file mode 100644 index 0000000..5f4b4b2 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/dottedline.imageset/dottedline.svg @@ -0,0 +1,6 @@ + + + + + + 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/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 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/Contents.json new file mode 100644 index 0000000..900b128 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "hourglass.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/hourglass.svg b/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/hourglass.svg new file mode 100644 index 0000000..f229793 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/hourglass.imageset/hourglass.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/Contents.json new file mode 100644 index 0000000..3e19bc2 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "incoming@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "incoming@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "incoming@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@1x.png b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@1x.png new file mode 100644 index 0000000..09bdaf4 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@2x.png b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@2x.png new file mode 100644 index 0000000..267578a Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@3x.png b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@3x.png new file mode 100644 index 0000000..433e6a2 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/incoming.imageset/incoming@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/Contents.json new file mode 100644 index 0000000..8cd6366 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "intrologo@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "intrologo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "intrologo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@1x.png b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@1x.png new file mode 100644 index 0000000..de433f1 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@2x.png b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@2x.png new file mode 100644 index 0000000..0810427 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@3x.png b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@3x.png new file mode 100644 index 0000000..e3e80d0 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/intrologo.imageset/intrologo@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/Contents.json new file mode 100644 index 0000000..b0bb50e --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "lack@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lack@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lack@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@1x.png b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@1x.png new file mode 100644 index 0000000..fc64ec3 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@2x.png b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@2x.png new file mode 100644 index 0000000..fb7fdd8 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@3x.png b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@3x.png new file mode 100644 index 0000000..7906523 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/lack.imageset/lack@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/Contents.json new file mode 100644 index 0000000..e82017e --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "lightbulb.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/lightbulb.svg b/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/lightbulb.svg new file mode 100644 index 0000000..e6ad3ff --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/lightbulb.imageset/lightbulb.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/location.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/location.imageset/Contents.json new file mode 100644 index 0000000..be50dad --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/location.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "location.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/location.imageset/location.svg b/StockMate/StockMate/resources/Assets.xcassets/location.imageset/location.svg new file mode 100644 index 0000000..79f625f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/location.imageset/location.svg @@ -0,0 +1,8 @@ + + + + + + + + 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/notiImage.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/notiImage.imageset/Contents.json new file mode 100644 index 0000000..f5714cb --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notiImage.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "notiImage.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/notiImage.imageset/notiImage.svg b/StockMate/StockMate/resources/Assets.xcassets/notiImage.imageset/notiImage.svg new file mode 100644 index 0000000..de12ab5 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notiImage.imageset/notiImage.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/notification.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/notification.imageset/Contents.json new file mode 100644 index 0000000..5dabea1 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notification.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.imageset/notification.svg b/StockMate/StockMate/resources/Assets.xcassets/notification.imageset/notification.svg new file mode 100644 index 0000000..a28bd9f --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/notification.imageset/notification.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/package.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/package.imageset/Contents.json new file mode 100644 index 0000000..f289ed5 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/package.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "package.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/package.imageset/package.svg b/StockMate/StockMate/resources/Assets.xcassets/package.imageset/package.svg new file mode 100644 index 0000000..c2f544d --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/package.imageset/package.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/Contents.json new file mode 100644 index 0000000..01ce80a --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pindrop.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/pindrop.svg b/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/pindrop.svg new file mode 100644 index 0000000..f9cfacb --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/pindrop.imageset/pindrop.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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/rocket.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/rocket.imageset/Contents.json new file mode 100644 index 0000000..a61a5d8 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/rocket.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "rocket.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/rocket.imageset/rocket.svg b/StockMate/StockMate/resources/Assets.xcassets/rocket.imageset/rocket.svg new file mode 100644 index 0000000..b63f40e --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/rocket.imageset/rocket.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/Contents.json new file mode 100644 index 0000000..b5a8e22 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "shoppingcart.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/shoppingcart.svg b/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/shoppingcart.svg new file mode 100644 index 0000000..bc001c9 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/shoppingcart.imageset/shoppingcart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/Contents.json new file mode 100644 index 0000000..7fcd63e --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "spanner.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/spanner.svg b/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/spanner.svg new file mode 100644 index 0000000..8203013 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/spanner.imageset/spanner.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/Contents.json new file mode 100644 index 0000000..10b8a40 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stockmate_logo@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stockmate_logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stockmate_logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@1x.png b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@1x.png new file mode 100644 index 0000000..0b4cb42 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@2x.png b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@2x.png new file mode 100644 index 0000000..f7ea6fe Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@3x.png b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@3x.png new file mode 100644 index 0000000..11a3e3e Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/stockmate_logo.imageset/stockmate_logo@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/Contents.json new file mode 100644 index 0000000..a1b0717 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabHome.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/tabHome.svg b/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/tabHome.svg new file mode 100644 index 0000000..7539862 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabHome.imageset/tabHome.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/Contents.json new file mode 100644 index 0000000..53286b8 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabInventory.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/tabInventory.svg b/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/tabInventory.svg new file mode 100644 index 0000000..cf21e73 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabInventory.imageset/tabInventory.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/Contents.json new file mode 100644 index 0000000..d90fcd3 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabPackage.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/tabPackage.svg b/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/tabPackage.svg new file mode 100644 index 0000000..f8c3043 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabPackage.imageset/tabPackage.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/Contents.json new file mode 100644 index 0000000..25e0b53 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tabProfile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/tabProfile.svg b/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/tabProfile.svg new file mode 100644 index 0000000..c98979c --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/tabProfile.imageset/tabProfile.svg @@ -0,0 +1,3 @@ + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/Contents.json new file mode 100644 index 0000000..a3a0113 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "toastlogo@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "toastlogo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "toastlogo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@1x.png b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@1x.png new file mode 100644 index 0000000..9ffd3ba Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@2x.png b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@2x.png new file mode 100644 index 0000000..1193e9d Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@3x.png b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@3x.png new file mode 100644 index 0000000..3d0fa5d Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/toastlogo.imageset/toastlogo@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/Contents.json new file mode 100644 index 0000000..cd18e01 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "transfer@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "transfer@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "transfer@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@1x.png b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@1x.png new file mode 100644 index 0000000..24c23e1 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@2x.png b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@2x.png new file mode 100644 index 0000000..6ef8463 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@3x.png b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@3x.png new file mode 100644 index 0000000..31120bb Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/transfer.imageset/transfer@3x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/Contents.json new file mode 100644 index 0000000..a1bff87 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "uploadprogress.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/uploadprogress.svg b/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/uploadprogress.svg new file mode 100644 index 0000000..3e65143 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/uploadprogress.imageset/uploadprogress.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 @@ + + + + diff --git a/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/Contents.json b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/Contents.json new file mode 100644 index 0000000..ddd1570 --- /dev/null +++ b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "wait@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "wait@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "wait@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@1x.png b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@1x.png new file mode 100644 index 0000000..86e50be Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@1x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@2x.png b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@2x.png new file mode 100644 index 0000000..15de6d4 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@2x.png differ diff --git a/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@3x.png b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@3x.png new file mode 100644 index 0000000..38f3d21 Binary files /dev/null and b/StockMate/StockMate/resources/Assets.xcassets/wait.imageset/wait@3x.png differ diff --git a/StockMate/StockMate/resources/Color.swift b/StockMate/StockMate/resources/Color.swift new file mode 100644 index 0000000..a5baa30 --- /dev/null +++ b/StockMate/StockMate/resources/Color.swift @@ -0,0 +1,107 @@ +// +// StockMateApp.swift +// StockMate +// +// Created by Admin on 10/5/25. +// + + +import Foundation +import SwiftUI + +extension Color { + + //keyColor + static let Primary = Color(hex: "#1D4ED8") + static let Secondary = Color(hex: "#60A5FA") + + // DarkBlue + static let DarkBlue01 = Color(hex: "#182D53") + static let DarkBlue02 = Color(hex: "#1E3A8A") + static let DarkBlue03 = Color(hex: "#1E40AF") + + // LightBlue + static let LightBlue01 = Color(hex: "#2563EB") + static let LightBlue02 = Color(hex: "#3B82F6") + static let LightBlue03 = Color(hex: "#93C5FD") + static let LightBlue04 = Color(hex: "#DBEAFE") + + // Etc + static let Gray = Color(hex: "#ABABAB") + static let GrayStroke = Color(hex: "#DBDBDB") + static let White = Color(hex: "#FFFFFF") + static let Dark = Color(hex: "#04150C") + static let Light = Color(hex: "#F7F7F7") + + static let GrayMordern300 = Color(hex: "#CDD5DF") + static let GrayMordern400 = Color(hex: "#9AA4B2") + + + + // Text + static let textBlack = Color(hex: "#152C07") + static let textGray1 = Color(hex: "#5D5C5D") + static let textGray2 = Color(hex: "#BEBEBE") + + // State + static let Success = Color(hex: "#00CB6A") + static let Incoming = Color(hex: "#5EDE99") + static let Danger = Color(hex: "#F26666") + static let Warning = Color(hex: "#EFBE24") + static let Defect = Color(hex: "#FF9E29") + static let Transfer = Color(hex: "#4DC2EC") + + // State Background Color + static let SuccessBg = Color(hex: "#00CB6A") + static let IncomingBg = Color(hex: "#E0FFF0") + static let DangerBg = Color(hex: "#FFE0E0") + static let WarningBg = Color(hex: "#FFFCE0") + static let DefectBg = Color(hex: "#FFDEB7") + static let TransferBg = Color(hex: "#E3F5FC") + + + // Inventory Status + static let InvIncoming = Color(hex: "#7CE12F") + static let InvUse = Color(hex: "#F6AD12") + static let InvStock = Color(hex: "#C1ACFF") + + // Inventory Status Background + static let InvIncomingBg = Color(hex: "#EAFADE") + static let InvUseBg = Color(hex: "#FEF2D9") + static let InvStockBg = Color(hex: "#F6F4FF") + + + // Status + static let StatusGreen = Color(hex: "#82E239") + static let StatusRed = Color(hex: "#F34838") + static let StatusPurple = Color(hex: "#7562FF") + + // Status Background + static let StatusGreenBg = Color(hex: "#EAFADE") + static let StatusRedBg = Color(hex: "#FDE0DD") + static let StatusPurpleBg = Color(hex: "#E9E6FF") + + + + //DFF6FC + // Home Status + 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") + + // Home Status Background + static let Hstatus1Bg = Color(hex: "#DFF6FC") + static let Hstatus2Bg = Color(hex: "#DBDFF3") + static let Hstatus3Bg = Color(hex: "#FCE3DE") + static let Hstatus4Bg = Color(hex: "#EDF9E4") + static let Hstatus5Bg = Color(hex: "#ECEEF0") + + + static let Grayline = Color(hex: "#ECECED") + static let Grayline2 = Color(hex: "#DDDDDD") + + // 투명도 포함 예시 + static let boxBgWhite = Color(hex: "#40FFFFFF") // 투명도 포함 +} diff --git a/StockMate/StockMate/resources/ColorHex.swift b/StockMate/StockMate/resources/ColorHex.swift new file mode 100644 index 0000000..02fa4c8 --- /dev/null +++ b/StockMate/StockMate/resources/ColorHex.swift @@ -0,0 +1,41 @@ +// +// StockMateApp.swift +// StockMate +// +// Created by Admin on 10/5/25. +// + + +import SwiftUI + +extension Color { + init(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&rgb) + + let r, g, b, a: Double + + switch hexSanitized.count { + case 6: // RGB (예: FFFFFF) + r = Double((rgb >> 16) & 0xFF) / 255.0 + g = Double((rgb >> 8) & 0xFF) / 255.0 + b = Double(rgb & 0xFF) / 255.0 + a = 1.0 + + case 8: // ARGB (예: 40FFFFFF → 투명 흰색) + a = Double((rgb >> 24) & 0xFF) / 255.0 + r = Double((rgb >> 16) & 0xFF) / 255.0 + g = Double((rgb >> 8) & 0xFF) / 255.0 + b = Double(rgb & 0xFF) / 255.0 + + default: + r = 0; g = 0; b = 0; a = 1 + } + + self.init(.sRGB, red: r, green: g, blue: b, opacity: a) + } +} +