diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 301f583..5475e0c 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,7 +1,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 7146cb1..8647f1d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a39a03..8df3fc7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,9 @@ android { dependencies { // 기존 의존성 유지 implementation(project(":core:ui")) + implementation(project(":data")) + implementation(project(":domain")) + implementation(project(":core:common")) implementation(project(":presentation")) implementation(project(":feature:onboarding")) implementation(project(":feature:auth")) @@ -80,6 +83,8 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.navigation:navigation-compose:2.7.5") + implementation("androidx.hilt:hilt-navigation-compose:1.1.0") + implementation(libs.balloon) implementation(libs.core.splashscreen) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fcbf187..3c88935 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,27 +2,61 @@ + + + + + + + + + + + + + + + + - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt b/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt index e31cf6d..9d88cd3 100644 --- a/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt +++ b/app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt @@ -1,4 +1,4 @@ -// app/src/main/java/com/example/barrion/navigation/BarrionNavHost.kt (최종 버전) +// BarrionNavHost.kt - 메인 네비게이션 호스트 (수정된 버전) package com.example.barrion.navigation import androidx.compose.foundation.layout.Box @@ -8,20 +8,26 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument import com.barrion.navigation.HomeScreen import com.example.auth.screen.LoginScreen import com.example.auth.screen.WelcomeScreen -import com.example.menu.screen.MenuScreen +import com.example.menu.screen.AddMenuScreen +import com.example.menu.screen.CategoryDetailScreen +import com.example.menu.screen.CategoryManagementScreen +import com.example.menu.screen.EditMenuScreen +import com.example.menu.viewmodel.MenuViewModel import com.example.onboarding.presentation.OnboardingScreen import com.example.onboarding.presentation.SetupStoreInfoScreen import com.example.onboarding.presentation.SetupBusinessTypeScreen import com.example.onboarding.presentation.SetupKioskCategoryScreen -import com.example.order.screen.OrderScreen -import com.example.sales.screen.SalesScreen -import com.example.staff.screen.StaffScreen +import com.example.staff.screen.StaffDetailScreen +import com.example.staff.screen.StaffEditScreen /** * 앱의 메인 네비게이션 호스트 @@ -113,26 +119,124 @@ fun BarrionNavHost(navController: NavHostController) { ) } - // 홈 화면 - 바텀 네비게이션 포함 + // ✅ 홈 화면 - 바텀 네비게이션 포함 (모든 탭은 여기서 관리) composable(route = NavRoutes.Home.route) { - HomeScreen() + HomeScreen(navController = navController) } - // 바텀 네비게이션 화면들 - composable(route = NavRoutes.Menu.route) { - MenuScreen() + // ========== 메뉴 관리 상세 화면들 ========== + composable(route = NavRoutes.CategoryManagement.route) { + val viewModel: MenuViewModel = hiltViewModel() + CategoryManagementScreen( + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + } + ) } - composable(route = NavRoutes.Orders.route) { - OrderScreen() + composable( + route = NavRoutes.CategoryDetail.route, + arguments = listOf( + navArgument("categoryId") { type = NavType.LongType }, + navArgument("categoryName") { type = NavType.StringType } + ) + ) { backStackEntry -> + val categoryId = backStackEntry.arguments?.getLong("categoryId") ?: 0L + val categoryName = backStackEntry.arguments?.getString("categoryName") ?: "" + val viewModel: MenuViewModel = hiltViewModel() + + CategoryDetailScreen( + categoryId = categoryId, + categoryName = categoryName, + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToAddMenu = { categoryId -> + navController.navigate(NavRoutes.AddMenu.createRoute(categoryId)) + }, + onNavigateToEditMenu = { menuId -> + navController.navigate(NavRoutes.EditMenu.createRoute(menuId)) + } + ) } - composable(route = NavRoutes.Sales.route) { - SalesScreen() + composable( + route = NavRoutes.AddMenu.route, + arguments = listOf( + navArgument("categoryId") { + type = NavType.LongType + defaultValue = 0L + nullable = false + } + ) + ) { backStackEntry -> + val categoryId = backStackEntry.arguments?.getLong("categoryId")?.takeIf { it != 0L } + val viewModel: MenuViewModel = hiltViewModel() + + AddMenuScreen( + selectedCategoryId = categoryId, + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + } + ) } - composable(route = NavRoutes.Staff.route) { - StaffScreen() + composable( + route = NavRoutes.EditMenu.route, + arguments = listOf( + navArgument("menuId") { type = NavType.LongType } + ) + ) { backStackEntry -> + val menuId = backStackEntry.arguments?.getLong("menuId") ?: 0L + val viewModel: MenuViewModel = hiltViewModel() + + EditMenuScreen( + menuId = menuId, + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + + // ========== 직원 관리 상세 화면들 ========== + + // ✅ Staff 상세 화면 - onEditClick 파라미터 제거 + composable( + route = NavRoutes.StaffDetail.route, + arguments = listOf(navArgument("staffId") { type = NavType.LongType }) + ) { backStackEntry -> + val staffId = backStackEntry.arguments?.getLong("staffId") ?: 0L + StaffDetailScreen( + staffId = staffId, + // ✅ onEditClick 파라미터 제거됨 + onBack = { + navController.popBackStack() + } + ) + } + + composable( + route = NavRoutes.StaffEdit.route, + arguments = listOf( + navArgument("id") { + type = NavType.LongType + defaultValue = 0L + nullable = false + } + ) + ) { backStackEntry -> + val staffId = backStackEntry.arguments?.getLong("id")?.takeIf { it != 0L } + + StaffEditScreen( + staffId = staffId, + onBack = { + navController.popBackStack() + } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt b/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt index eef48cc..8c170dd 100644 --- a/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt +++ b/app/src/main/java/com/example/barrion/navigation/HomeScreen.kt @@ -1,42 +1,63 @@ -// app/src/main/java/com/barrion/navigation/HomeScreen.kt +// HomeScreen.kt - 바텀 네비게이션이 포함된 홈 화면 package com.barrion.navigation +import OrderScreen import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.example.menu.screen.MenuScreen -import com.example.order.screen.OrderScreen +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.example.barrion.navigation.NavRoutes +import com.example.menu.screen.MenuMviScreen +import com.example.menu.viewmodel.MenuViewModel +import com.example.order.viewmodel.OrderViewModel import com.example.sales.screen.SalesScreen import com.example.staff.screen.StaffScreen import com.example.ui.components.navigation.BarrionBottomNavigation +import com.example.ui.theme.barrionColors +// HomeScreen.kt 수정 부분 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeScreen() { - var currentRoute by remember { mutableStateOf("menu") } +fun HomeScreen( + navController: NavHostController +) { + var currentRoute by rememberSaveable { mutableStateOf(NavRoutes.Menu.route) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val menuViewModel: MenuViewModel = hiltViewModel() + // ✅ OrderViewModel 추가 + val orderViewModel: OrderViewModel = hiltViewModel() + + // 백스택 변화 감지 (기존 코드 유지) + LaunchedEffect(currentBackStackEntry) { + val route = currentBackStackEntry?.destination?.route + + when { + route == NavRoutes.Home.route -> { + // Home으로 직접 돌아온 경우, 마지막 활성 탭 유지 + } + route?.contains("staff") == true -> { + currentRoute = NavRoutes.Staff.route + } + route?.contains("menu") == true || + route?.contains("category") == true -> { + currentRoute = NavRoutes.Menu.route + } + route?.contains("order") == true -> { + currentRoute = NavRoutes.Orders.route + } + route?.contains("sales") == true -> { + currentRoute = NavRoutes.Sales.route + } + } + } Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = when (currentRoute) { - "sales" -> "매출 관리" - "menu" -> "메뉴 관리" - "orders" -> "주문 관리" - "staff" -> "직원 관리" - else -> "Barrion" - } - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - }, + modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.barrionColors.white, bottomBar = { BarrionBottomNavigation( currentRoute = currentRoute, @@ -44,28 +65,50 @@ fun HomeScreen() { currentRoute = route } ) - } + }, + contentWindowInsets = WindowInsets(0, 0, 0, 0) ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .padding(bottom = innerPadding.calculateBottomPadding()) ) { when (currentRoute) { - "sales" -> SalesScreen() - "menu" -> MenuScreen() - "orders" -> OrderScreen() - "staff" -> StaffScreen() - else -> MenuScreen() // 기본값 + NavRoutes.Sales.route -> SalesScreen() + + // ✅ OrderScreen에 ViewModel 전달 + NavRoutes.Orders.route -> OrderScreen( + viewModel = orderViewModel + ) + + NavRoutes.Staff.route -> StaffScreen( + onNavigateToDetail = { staffId -> + navController.navigate(NavRoutes.StaffDetail.createRoute(staffId)) + }, + onNavigateToAdd = { + navController.navigate(NavRoutes.StaffEdit.createRoute()) + } + ) + + NavRoutes.Menu.route -> MenuMviScreen( + viewModel = menuViewModel, + onNavigateToCategoryManagement = { + navController.navigate(NavRoutes.CategoryManagement.route) + }, + onNavigateToAddMenu = { + navController.navigate(NavRoutes.AddMenu.createRoute()) + }, + onNavigateToCategoryDetail = { categoryId, categoryName -> + navController.navigate( + NavRoutes.CategoryDetail.createRoute(categoryId, categoryName) + ) + }, + onNavigateToEditMenu = { menuId -> + navController.navigate(NavRoutes.EditMenu.createRoute(menuId)) + } + ) + else -> Text("정의되지 않은 경로입니다.") } } } -} - -@Preview -@Composable -private fun HomeScreenPreview() { - MaterialTheme { - HomeScreen() - } } \ No newline at end of file diff --git a/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt b/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt index 3cc898b..81367a4 100644 --- a/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt +++ b/app/src/main/java/com/example/barrion/navigation/NavRoutes.kt @@ -1,35 +1,50 @@ -// app/src/main/java/com/example/barrion/navigation/NavRoutes.kt (수정된 부분) +// NavRoutes.kt - 네비게이션 경로 정의 package com.example.barrion.navigation +// 앱 전체의 네비게이션 경로를 정의한 sealed class sealed class NavRoutes(val route: String) { - /** - * 온보딩 화면 경로 - 앱 최초 실행 시 표시되는 화면 - */ - object Onboarding : NavRoutes("onboarding") - /** - * Welcome 화면 경로 - 로그인 버튼이 있는 중간 화면 - */ + // 온보딩 플로우 (최초 실행 시) + object Onboarding : NavRoutes("onboarding") object Welcome : NavRoutes("welcome") - - /** - * 로그인 화면 경로 - 코드 입력 화면 - */ object Login : NavRoutes("login") - - /** - * 홈 화면 경로 - 바텀 네비게이션이 포함된 메인 화면 - */ object Home : NavRoutes("home") - // Setup 플로우 - object SetupStoreInfo : NavRoutes("setup_store_info") - object SetupBusinessType : NavRoutes("setup_business_type") - object SetupKioskCategory : NavRoutes("setup_kiosk_category") + // 설정 플로우 (Setup) + object SetupStoreInfo : NavRoutes("setup_store_info") // 상호명 입력 화면 + object SetupBusinessType : NavRoutes("setup_business_type") // 업종 선택 화면 + object SetupKioskCategory : NavRoutes("setup_kiosk_category") // 키오스크 카테고리 선택 화면 + + // 바텀 탭 화면들 + object Menu : NavRoutes("menu") // 메뉴 관리 탭 + object Orders : NavRoutes("orders") // 주문 관리 탭 + object Sales : NavRoutes("sales") // 매출 탭 + object Staff : NavRoutes("staff") // 직원 관리 탭 + + // 메뉴 관리 관련 상세 화면들 + object CategoryManagement : NavRoutes("category_management") // 카테고리 전체 관리 화면 + + object CategoryDetail : NavRoutes("category_detail/{categoryId}/{categoryName}") { + fun createRoute(categoryId: Long, categoryName: String): String = + "category_detail/$categoryId/$categoryName" // 특정 카테고리 내 메뉴 보기 + } + + object AddMenu : NavRoutes("add_menu?categoryId={categoryId}") { + fun createRoute(categoryId: Long? = null): String = + categoryId?.let { "add_menu?categoryId=$it" } ?: "add_menu" // 새 메뉴 등록 + } + + object EditMenu : NavRoutes("edit_menu/{menuId}") { + fun createRoute(menuId: Long): String = "edit_menu/$menuId" // 기존 메뉴 수정 + } + + // 직원 관리 관련 상세 화면들 + object StaffDetail : NavRoutes("staff_detail/{staffId}") { + fun createRoute(staffId: Long): String = "staff_detail/$staffId" // 직원 상세 보기 + } - // 바텀 네비게이션 화면들 - object Menu : NavRoutes("menu") - object Orders : NavRoutes("orders") - object Sales : NavRoutes("sales") - object Staff : NavRoutes("staff") + object StaffEdit : NavRoutes("staff_edit?id={id}") { + fun createRoute(id: Long? = null): String = + id?.let { "staff_edit?id=$it" } ?: "staff_edit" // 직원 등록 또는 수정 + } } \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..f6c692c --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..b1475d9 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + 13.209.99.95 + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index cf79183..99fc78a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,5 +15,6 @@ plugins { alias(libs.plugins.jetbrains.kotlin.jvm) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + // ktlint 플러그인 제거 } \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 3c53d28..f397668 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -9,4 +9,5 @@ android { dependencies { implementation(project(":core:common")) + implementation("androidx.compose.material:material-icons-extended:1.5.4") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt b/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt index 7bada9c..1537f6b 100644 --- a/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt +++ b/core/ui/src/main/java/com/example/ui/components/navigation/BottomNavigationBar.kt @@ -1,15 +1,20 @@ -// core/ui/src/main/java/com/barrion/core/ui/components/navigation/BottomNavigationBar.kt package com.example.ui.components.navigation import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Assessment +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.RestaurantMenu +import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors // 네비게이션 아이템 데이터 클래스 data class BottomNavItem( @@ -18,13 +23,13 @@ data class BottomNavItem( val label: String ) -// 바텀 네비게이션 아이템들 정의 +// 바텀 네비게이션 아이템들 정의 - 아이콘 수정 object BottomNavItems { val items = listOf( - BottomNavItem("sales", Icons.Default.Star, "매출"), - BottomNavItem("menu", Icons.Default.Home, "메뉴"), - BottomNavItem("orders", Icons.Default.Notifications, "주문"), - BottomNavItem("staff", Icons.Default.Person, "직원") + BottomNavItem("sales", Icons.Outlined.Assessment, "매출"), + BottomNavItem("menu", Icons.Outlined.RestaurantMenu, "메뉴"), // 메뉴판 아이콘 + BottomNavItem("orders", Icons.Outlined.ShoppingCart, "주문"), + BottomNavItem("staff", Icons.Outlined.Person, "직원") ) } @@ -35,16 +40,20 @@ fun BarrionBottomNavigation( modifier: Modifier = Modifier ) { NavigationBar( - modifier = modifier.fillMaxWidth(), - containerColor = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp + modifier = modifier + .fillMaxWidth() + .height(60.dp), // 더 작게 + containerColor = MaterialTheme.barrionColors.white, + tonalElevation = 0.dp, // 그림자 완전 제거 + windowInsets = WindowInsets(0, 0, 0, 0) // 모든 여백 제거 ) { BottomNavItems.items.forEach { item -> NavigationBarItem( icon = { Icon( imageVector = item.icon, - contentDescription = item.label + contentDescription = item.label, + modifier = Modifier.size(20.dp) // 아이콘 크기 조정 ) }, label = { @@ -56,11 +65,14 @@ fun BarrionBottomNavigation( selected = currentRoute == item.route, onClick = { onNavigate(item.route) }, colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.primary, - selectedTextColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - indicatorColor = MaterialTheme.colorScheme.primaryContainer + // 선택된 상태 + selectedIconColor = MaterialTheme.barrionColors.white, + selectedTextColor = MaterialTheme.barrionColors.white, + indicatorColor = MaterialTheme.barrionColors.primaryBlue, + + // 선택되지 않은 상태 + unselectedIconColor = MaterialTheme.barrionColors.grayMedium, + unselectedTextColor = MaterialTheme.barrionColors.grayMedium ) ) } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index a201e79..d4e9172 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -2,16 +2,50 @@ plugins { id("barrion.android.library") id("barrion.network") // 네트워크 통신 관련 id("barrion.hilt") // 의존성 주입 필요한 경우 + id("barrion.imageloading") + + + id("com.android.library") + id("org.jetbrains.kotlin.android") + // kapt 대신 ksp 사용 + id("com.google.devtools.ksp") + id("dagger.hilt.android.plugin") } android { namespace = "com.example.data" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } } dependencies { // 도메인 모듈 의존성 implementation(project(":domain")) + + // Hilt - ksp 사용 + implementation("com.google.dagger:hilt-android:2.48") + ksp("com.google.dagger:hilt-compiler:2.48") // kapt → ksp + + + // JSON + implementation("com.google.code.gson:gson:2.10.1") + + // SharedPreferences는 Android 기본 라이브러리에 포함되어 있으므로 // 따로 의존성을 추가할 필요가 없습니다. // androidx.core:core-ktx는 AndroidLibraryConventionPlugin에서 이미 추가됨 diff --git a/data/src/main/java/com/example/data/api/CategoryApi.kt b/data/src/main/java/com/example/data/api/CategoryApi.kt new file mode 100644 index 0000000..f26c9be --- /dev/null +++ b/data/src/main/java/com/example/data/api/CategoryApi.kt @@ -0,0 +1,47 @@ +// data/api/CategoryApi.kt +package com.example.data.api + +import com.example.data.dto.CategoryCreateRequest +import com.example.data.dto.CategoryDto +import retrofit2.Response +import retrofit2.http.* + +/** + * 카테고리 관련 API 엔드포인트 정의 + * 카테고리 CRUD 작업을 처리하는 Retrofit 인터페이스 + */ +interface CategoryApi { + + /** + * 모든 카테고리 목록 조회 + * + * @return 카테고리 목록을 담은 Response + * API: GET /api/categories + */ + @GET("api/categories") + suspend fun getCategories(): Response> + + /** + * 새로운 카테고리 생성 + * + * @param request 생성할 카테고리 정보 (이름, ID) + * @return 생성된 카테고리 정보를 담은 Response + * API: POST /api/categories + */ + @POST("api/categories") + suspend fun createCategory( + @Body request: CategoryCreateRequest + ): Response + + /** + * 특정 카테고리 삭제 + * + * @param categoryId 삭제할 카테고리의 ID + * @return 삭제 결과를 담은 Response (성공 시 빈 응답) + * API: DELETE /api/categories/{id} + */ + @DELETE("api/categories/{id}") + suspend fun deleteCategory( + @Path("id") categoryId: Long // Int에서 Long으로 변경 + ): Response +} diff --git a/data/src/main/java/com/example/data/api/MenuApi.kt b/data/src/main/java/com/example/data/api/MenuApi.kt new file mode 100644 index 0000000..e948450 --- /dev/null +++ b/data/src/main/java/com/example/data/api/MenuApi.kt @@ -0,0 +1,113 @@ +// data/api/MenuApi.kt +package com.example.data.api + +import com.example.data.dto.* +import retrofit2.Response +import retrofit2.http.* + +/** + * 메뉴 관련 API 엔드포인트 정의 + * 메뉴 CRUD 작업과 옵션 관리를 처리하는 Retrofit 인터페이스 + */ +interface MenuApi { + + /** + * 메뉴 목록 조회 (페이지네이션 지원) + * + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (한 페이지당 항목 수, 기본값: 100) + * @param category 카테고리 필터 (null이면 전체 메뉴 조회) + * @return 페이지네이션된 메뉴 목록을 담은 Response + * API: GET /api/menus?page=0&size=100&category=1 + */ + @GET("api/menus") + suspend fun getMenus( + @Query("page") page: Int = 0, + @Query("size") size: Int = 100, + @Query("category") category: Long? = null // Int에서 Long으로 변경 + ): Response + + /** + * 특정 메뉴 상세 정보 조회 + * + * @param menuId 조회할 메뉴의 ID + * @return 메뉴 상세 정보를 담은 Response + * API: GET /api/menus/{menuId} + */ + @GET("api/menus/{menuId}") + suspend fun getMenu( + @Path("menuId") menuId: Long // Int에서 Long으로 변경 + ): Response + + /** + * 새로운 메뉴 생성 + * + * @param request 생성할 메뉴 정보 (이름, 가격, 설명, 카테고리, base64 이미지 등) + * @return 생성된 메뉴 정보를 담은 Response + * API: POST /api/menus + */ + @POST("api/menus") + suspend fun createMenu( + @Body request: MenuCreateRequest + ): Response + + /** + * 기존 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴의 ID + * @param request 수정할 메뉴 정보 (이름, 가격, 설명, 카테고리, base64 이미지 등) + * @return 수정된 메뉴 정보를 담은 Response + * API: PUT /api/menus/{menuId} + */ + @PUT("api/menus/{menuId}") + suspend fun updateMenu( + @Path("menuId") menuId: Long, // Int에서 Long으로 변경 + @Body request: MenuUpdateRequest + ): Response + + /** + * 특정 메뉴 삭제 + * + * @param menuId 삭제할 메뉴의 ID + * @return 삭제 결과를 담은 Response (성공 시 빈 응답) + * API: DELETE /api/menus/{menuId} + */ + @DELETE("api/menus/{menuId}") + suspend fun deleteMenu( + @Path("menuId") menuId: Long // Int에서 Long으로 변경 + ): Response + + /** + * 특정 메뉴의 옵션 목록 조회 + * + * @param menuId 옵션을 조회할 메뉴의 ID + * @return 메뉴 옵션 목록을 담은 Response + * API: GET /api/menus/{menuId}/options + * + * 참고: 현재는 사용하지 않지만 향후 확장을 위해 주석 처리 + */ + /* + @GET("api/menus/{menuId}/options") + suspend fun getMenuOptions( + @Path("menuId") menuId: Int + ): Response> + */ + + /** + * 메뉴에 새로운 옵션 추가 + * + * @param menuId 옵션을 추가할 메뉴의 ID + * @param request 추가할 옵션 정보 + * @return 추가된 옵션 정보를 담은 Response + * API: POST /api/menus/{menuId}/options + * + * 참고: 현재는 사용하지 않지만 향후 확장을 위해 주석 처리 + */ + /* + @POST("api/menus/{menuId}/options") + suspend fun createMenuOption( + @Path("menuId") menuId: Int, + @Body request: MenuOptionCreateRequest + ): Response + */ +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/api/OrderApi.kt b/data/src/main/java/com/example/data/api/OrderApi.kt new file mode 100644 index 0000000..8a86ef8 --- /dev/null +++ b/data/src/main/java/com/example/data/api/OrderApi.kt @@ -0,0 +1,27 @@ +package com.example.data.api + +import com.example.data.dto.OrderDto +import retrofit2.Response +import retrofit2.http.* + +interface OrderApi { + // 매장별 주문 조회 + @GET("/api/orders/store/{storeId}") + suspend fun getAllOrders(@Path("storeId") storeId: Int): Response> + + // 주문 삭제 + @DELETE("/api/orders/{orderId}") + suspend fun deleteOrder(@Path("orderId") orderId: Int): Response + + // 단일 주문 조회 (필요시 사용) + @GET("/api/orders/{orderId}") + suspend fun getOrder(@Path("orderId") orderId: Int): Response + + // 주문 수정 (필요시 사용) + @PUT("/api/orders/{orderId}") + suspend fun updateOrder(@Path("orderId") orderId: Int): Response + + // 주문 생성 (필요시 사용) + @POST("/api/orders") + suspend fun createOrder(@Body orderRequest: Any): Response +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/api/SalesApi.kt b/data/src/main/java/com/example/data/api/SalesApi.kt new file mode 100644 index 0000000..6dccd3d --- /dev/null +++ b/data/src/main/java/com/example/data/api/SalesApi.kt @@ -0,0 +1,31 @@ +package com.example.data.api + +// data/remote/api/SalesApi.kt + +import com.example.data.dto.SalesDataDto +import com.example.data.dto.TotalSalesDto +import retrofit2.http.GET + +/** + * 매출 관련 API 인터페이스 + */ +interface SalesApi { + + /** + * 총 매출 조회 + */ + @GET("sales/total") + suspend fun getTotalSales(): TotalSalesDto + + /** + * 연도별 매출 조회 + */ + @GET("sales/yearly") + suspend fun getYearlySales(): List + + /** + * 월별 매출 조회 + */ + @GET("sales/monthly") + suspend fun getMonthlySales(): List +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/api/StaffApi.kt b/data/src/main/java/com/example/data/api/StaffApi.kt new file mode 100644 index 0000000..ccc5d0f --- /dev/null +++ b/data/src/main/java/com/example/data/api/StaffApi.kt @@ -0,0 +1,52 @@ +// :data/src/main/java/com/example/data/api/StaffApi.kt +package com.example.data.api + +import com.example.data.dto.CreateStaffDto +import com.example.data.dto.StaffDto +import retrofit2.Response +import retrofit2.http.* + +/** + * 직원 관리 API 인터페이스 + * 서버 스펙: http://13.209.99.95:8080/swagger-ui/index.html + */ +interface StaffApi { + + /** + * 모든 직원 조회 + * GET /api/employees + */ + @GET("api/employees") + suspend fun getAllEmployees(): Response> + + /** + * 특정 직원 조회 + * GET /api/employees/{id} + */ + @GET("api/employees/{id}") + suspend fun getEmployeeById(@Path("id") id: Long): Response + + /** + * 직원 생성 + * POST /api/employees + */ + @POST("api/employees") + suspend fun createEmployee(@Body employee: CreateStaffDto): Response + + /** + * 직원 삭제 + * DELETE /api/employees/{id} + */ + @DELETE("api/employees/{id}") + suspend fun deleteEmployee(@Path("id") id: Long): Response + + // 참고: 서버에 PUT 업데이트 API가 없어서 주석 처리 + // 필요하면 나중에 서버에 추가 요청 + /* + @PUT("api/employees/{id}") + suspend fun updateEmployee( + @Path("id") id: Long, + @Body employee: UpdateStaffDto + ): Response + */ +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/NetworkModule.kt b/data/src/main/java/com/example/data/di/NetworkModule.kt new file mode 100644 index 0000000..cf78024 --- /dev/null +++ b/data/src/main/java/com/example/data/di/NetworkModule.kt @@ -0,0 +1,113 @@ +package com.example.data.di + +import com.example.data.api.CategoryApi +import com.example.data.api.MenuApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +/** + * 네트워크 관련 의존성 주입을 담당하는 Hilt 모듈 + * Retrofit, OkHttp, JSON 직렬화 등 네트워크 통신에 필요한 모든 객체를 제공 + */ +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + /** 백엔드 서버의 기본 URL */ + private const val BASE_URL = "http://13.209.99.95:8080/" + + /** + * Kotlin Serialization용 JSON 설정 + * - ignoreUnknownKeys: 서버에서 추가 필드가 와도 무시 + * - coerceInputValues: null 값을 기본값으로 변환 + * - encodeDefaults: 기본값도 JSON에 포함 + */ + @Provides + @Singleton + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true // 알 수 없는 키 무시 (API 호환성) + coerceInputValues = true // null 값 처리 + encodeDefaults = true // 기본값 인코딩 + } + } + + /** + * HTTP 로깅 인터셉터 제공 + * 개발 시 API 요청/응답을 로그로 확인할 수 있음 + */ + @Provides + @Singleton + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY // 요청/응답 본문까지 로깅 + } + } + + /** + * OkHttp 클라이언트 설정 + * - 로깅 인터셉터 추가 + * - 타임아웃 설정 (연결, 읽기, 쓰기 각각 30초) + */ + @Provides + @Singleton + fun provideOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) // HTTP 로깅 + .connectTimeout(30, TimeUnit.SECONDS) // 연결 타임아웃 + .readTimeout(30, TimeUnit.SECONDS) // 읽기 타임아웃 + .writeTimeout(30, TimeUnit.SECONDS) // 쓰기 타임아웃 + .build() + } + + /** + * Retrofit 인스턴스 제공 + * - Kotlin Serialization 컨버터 사용 + * - OkHttp 클라이언트 설정 적용 + */ + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + // Kotlin Serialization을 JSON 컨버터로 사용 + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + /** + * 메뉴 관련 API 인터페이스 제공 + * Retrofit이 구현체를 자동 생성 + */ + @Provides + @Singleton + fun provideMenuApi(retrofit: Retrofit): MenuApi { + return retrofit.create(MenuApi::class.java) + } + + /** + * 카테고리 관련 API 인터페이스 제공 + * Retrofit이 구현체를 자동 생성 + */ + @Provides + @Singleton + fun provideCategoryApi(retrofit: Retrofit): CategoryApi { + return retrofit.create(CategoryApi::class.java) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/OrderModule.kt b/data/src/main/java/com/example/data/di/OrderModule.kt new file mode 100644 index 0000000..58dbd02 --- /dev/null +++ b/data/src/main/java/com/example/data/di/OrderModule.kt @@ -0,0 +1,57 @@ +package com.example.data.di + +import com.example.data.api.OrderApi +import com.example.data.repository.OrderRepositoryImpl +import com.example.domain.repository.OrderRepository +import com.example.domain.usecase.order.DeleteOrderUseCase +import com.example.domain.usecase.order.GetAllOrdersUseCase +import com.example.domain.usecase.order.GetOrderUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import retrofit2.Retrofit + +@Module +@InstallIn(ViewModelComponent::class) +object OrderModule { + + @Provides + @ViewModelScoped + fun provideOrderApi(retrofit: Retrofit): OrderApi { + return retrofit.create(OrderApi::class.java) + } + + @Provides + @ViewModelScoped + fun provideOrderRepository( + api: OrderApi + ): OrderRepository { + return OrderRepositoryImpl(api) + } + + @Provides + @ViewModelScoped + fun provideGetAllOrdersUseCase( + repository: OrderRepository + ): GetAllOrdersUseCase { + return GetAllOrdersUseCase(repository) + } + + @Provides + @ViewModelScoped + fun provideGetOrderUseCase( + repository: OrderRepository + ): GetOrderUseCase { + return GetOrderUseCase(repository) + } + + @Provides + @ViewModelScoped + fun provideDeleteOrderUseCase( + repository: OrderRepository + ): DeleteOrderUseCase { + return DeleteOrderUseCase(repository) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/RepositoryModule.kt b/data/src/main/java/com/example/data/di/RepositoryModule.kt new file mode 100644 index 0000000..0ea4833 --- /dev/null +++ b/data/src/main/java/com/example/data/di/RepositoryModule.kt @@ -0,0 +1,24 @@ +package com.example.data.di + +import com.example.data.repository.MenuRepositoryImpl +import com.example.domain.repository.MenuRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Repository 바인딩을 위한 Dagger Hilt 모듈 + * - 인터페이스와 구현체를 연결 + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindMenuRepository( + menuRepositoryImpl: MenuRepositoryImpl + ): MenuRepository +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/SalesModule.kt b/data/src/main/java/com/example/data/di/SalesModule.kt new file mode 100644 index 0000000..369b541 --- /dev/null +++ b/data/src/main/java/com/example/data/di/SalesModule.kt @@ -0,0 +1,33 @@ +package com.example.data.di + +// di/SalesModule.kt + +import com.example.data.api.SalesApi +import com.example.data.repository.SalesRepositoryImpl +import com.example.domain.repository.SalesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * Sales 모듈 의존성 주입 설정 + */ +@Module +@InstallIn(SingletonComponent::class) +object SalesModule { + + @Provides + @Singleton + fun provideSalesApi(retrofit: Retrofit): SalesApi { + return retrofit.create(SalesApi::class.java) + } + + @Provides + @Singleton + fun provideSalesRepository(salesApi: SalesApi): SalesRepository { + return SalesRepositoryImpl(salesApi) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/di/StaffDataModule.kt b/data/src/main/java/com/example/data/di/StaffDataModule.kt new file mode 100644 index 0000000..da14d16 --- /dev/null +++ b/data/src/main/java/com/example/data/di/StaffDataModule.kt @@ -0,0 +1,32 @@ +// :data/src/main/java/com/example/data/di/StaffDataModule.kt +package com.example.data.di + +import com.example.data.api.StaffApi +import com.example.data.repository.StaffRepositoryImpl +import com.example.domain.repository.StaffRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class StaffDataModule { + + @Binds + @Singleton + abstract fun bindStaffRepository( + staffRepositoryImpl: StaffRepositoryImpl + ): StaffRepository + + companion object { + @Provides + @Singleton + fun provideStaffApi(retrofit: Retrofit): StaffApi { + return retrofit.create(StaffApi::class.java) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/dto/CategoryDto.kt b/data/src/main/java/com/example/data/dto/CategoryDto.kt new file mode 100644 index 0000000..07d13e1 --- /dev/null +++ b/data/src/main/java/com/example/data/dto/CategoryDto.kt @@ -0,0 +1,22 @@ +// data/dto/CategoryDto.kt +package com.example.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CategoryDto( + @SerialName("categoryId") + val categoryId: Long, // Int에서 Long으로 변경 + @SerialName("categoryName") + val categoryName: String +) + +@Serializable +data class CategoryCreateRequest( + @SerialName("categoryId") + val categoryId: Long, // Int에서 Long으로 변경 + @SerialName("categoryName") + val categoryName: String +) + diff --git a/data/src/main/java/com/example/data/dto/MenuDto.kt b/data/src/main/java/com/example/data/dto/MenuDto.kt new file mode 100644 index 0000000..dfb7eb3 --- /dev/null +++ b/data/src/main/java/com/example/data/dto/MenuDto.kt @@ -0,0 +1,71 @@ +// data/dto/MenuDto.kt +package com.example.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MenuDto( + @SerialName("menuId") + val menuId: Long, // Int에서 Long으로 변경 + @SerialName("category") + val category: Long, // Int에서 Long으로 변경 + @SerialName("menuName") + val menuName: String, + @SerialName("price") + val price: Long, // Int에서 Long으로 변경 + @SerialName("cost") + val cost: Long, // Int에서 Long으로 변경 + @SerialName("menuPresent") + val menuPresent: String?, // String을 String?로 변경 + @SerialName("menuImage") + val menuImage: String? = null, + @SerialName("options") + val options: List = emptyList() +) + +@Serializable +data class MenuCreateRequest( + @SerialName("category") + val category: Long, + @SerialName("menuName") + val menuName: String, + @SerialName("price") + val price: Long, + @SerialName("cost") + val cost: Long, + @SerialName("menuPresent") + val menuPresent: String, // String으로 되돌림 (서버가 String 기대) + @SerialName("base64Image") + val base64Image: String? = null +) + +@Serializable +data class MenuUpdateRequest( + @SerialName("category") + val category: Long, + @SerialName("menuName") + val menuName: String, + @SerialName("price") + val price: Long, + @SerialName("cost") + val cost: Long, + @SerialName("menuPresent") + val menuPresent: String, // String으로 되돌림 (서버가 String 기대) + @SerialName("base64Image") + val base64Image: String? = null +) + +@Serializable +data class MenuPageResponse( + @SerialName("content") + val content: List, + @SerialName("page") + val page: Int? = null, // nullable로 변경 + @SerialName("size") + val size: Int? = null, // nullable로 변경 + @SerialName("totalElements") + val totalElements: Int? = null, // nullable로 변경 + @SerialName("totalPages") + val totalPages: Int? = null // nullable로 변경 +) \ No newline at end of file diff --git a/data/src/main/java/com/example/data/dto/OrderDto.kt b/data/src/main/java/com/example/data/dto/OrderDto.kt new file mode 100644 index 0000000..7ae1878 --- /dev/null +++ b/data/src/main/java/com/example/data/dto/OrderDto.kt @@ -0,0 +1,23 @@ +package com.example.data.dto + +// data/dto/OrderDto.kt +import kotlinx.serialization.Serializable + +@Serializable +data class OrderDto( + val orderId: Int, + val storeId: Int, + val orderDate: String, // API 명세서와 일치 + val orderStatus: String, // API 명세서와 일치 + val items: List, // 주문 항목 추가 + val totalAmount: Int +) + +@Serializable +data class OrderItemDto( + val menuId: Int, + val menuName: String, + val quantity: Int, + val unitPrice: Int, + val totalPrice: Int +) \ No newline at end of file diff --git a/data/src/main/java/com/example/data/dto/SalesDto.kt b/data/src/main/java/com/example/data/dto/SalesDto.kt new file mode 100644 index 0000000..fc484ad --- /dev/null +++ b/data/src/main/java/com/example/data/dto/SalesDto.kt @@ -0,0 +1,24 @@ +package com.example.data.dto + +// data/remote/dto/SalesDto.kt + +import kotlinx.serialization.Serializable + +/** + * 총 매출 응답 DTO + */ +@Serializable +data class TotalSalesDto( + val salesDate: String, + val totalSales: Long +) + +/** + * 연도별/월별 매출 응답 DTO + * (배열 형태로 응답) + */ +@Serializable +data class SalesDataDto( + val salesDate: String, + val totalSales: Long +) \ No newline at end of file diff --git a/data/src/main/java/com/example/data/dto/StaffDto.kt b/data/src/main/java/com/example/data/dto/StaffDto.kt new file mode 100644 index 0000000..65327a1 --- /dev/null +++ b/data/src/main/java/com/example/data/dto/StaffDto.kt @@ -0,0 +1,59 @@ +// :data/src/main/java/com/example/data/dto/StaffDto.kt +package com.example.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * 서버 응답용 직원 DTO - 실제 서버 스펙 반영 + */ +@Serializable +data class StaffDto( + @SerialName("employeeId") + val employeeId: Long, + + @SerialName("storeId") + val storeId: Long, + + @SerialName("employeeName") + val employeeName: String, + + @SerialName("phoneNumber") + val phoneNumber: String, + + @SerialName("salary") + val salary: Int, // 시급 + + @SerialName("position") + val position: String, + + @SerialName("bankAccount") + val bankAccount: String +) + +/** + * 직원 생성용 DTO - POST 요청 시 사용 + */ +@Serializable +data class CreateStaffDto( + @SerialName("employeeId") + val employeeId: Long = 0, // 생성 시 0으로 전송 + + @SerialName("storeId") + val storeId: Long, + + @SerialName("employeeName") + val employeeName: String, + + @SerialName("phoneNumber") + val phoneNumber: String, + + @SerialName("salary") + val salary: Int, + + @SerialName("position") + val position: String, + + @SerialName("bankAccount") + val bankAccount: String +) \ No newline at end of file diff --git a/data/src/main/java/com/example/data/mapper/CategoryMapper.kt b/data/src/main/java/com/example/data/mapper/CategoryMapper.kt new file mode 100644 index 0000000..09a3303 --- /dev/null +++ b/data/src/main/java/com/example/data/mapper/CategoryMapper.kt @@ -0,0 +1,53 @@ +// data/mapper/CategoryMapper.kt +package com.example.data.mapper + +import com.example.data.dto.CategoryCreateRequest +import com.example.data.dto.CategoryDto +import com.example.domain.model.Category + +/** + * 카테고리 관련 데이터 변환을 담당하는 매퍼 함수들 + * DTO(Data Transfer Object)와 Domain Entity 간의 변환을 처리 + */ + +/** + * 서버 응답 DTO를 Domain 모델로 변환 + * + * @receiver CategoryDto 서버에서 받은 카테고리 데이터 + * @return Category Domain 계층에서 사용하는 카테고리 모델 + * + * 변환 규칙: + * - categoryId → id: 서버의 카테고리 ID를 Domain의 id로 매핑 + * - categoryName → name: 서버의 카테고리 이름을 Domain의 name으로 매핑 + * - order: API에 order 필드가 없으므로 categoryId를 Int로 변환하여 사용 + * - 나머지 필드들은 기본값 사용 + */ +fun CategoryDto.toDomain(): Category { + return Category( + id = categoryId, // Long 그대로 사용 + name = categoryName, + order = categoryId.toInt(), // Long을 Int로 변환 (order 필드용) + // API에 없는 필드들은 기본값 설정 + isDefault = false, + menuCount = 0, + createdAt = System.currentTimeMillis() + ) +} + +/** + * Domain 모델을 서버 생성 요청 DTO로 변환 + * + * @receiver Category Domain 계층의 카테고리 모델 + * @return CategoryCreateRequest 서버에 전송할 카테고리 생성 요청 데이터 + * + * 변환 규칙: + * - id → categoryId: Domain의 id를 서버의 categoryId로 매핑 + * - name → categoryName: Domain의 name을 서버의 categoryName으로 매핑 + */ +fun Category.toCreateRequest(): CategoryCreateRequest { + return CategoryCreateRequest( + categoryId = id, // Long 그대로 사용 + categoryName = name + ) +} + diff --git a/data/src/main/java/com/example/data/mapper/MenuMapper.kt b/data/src/main/java/com/example/data/mapper/MenuMapper.kt new file mode 100644 index 0000000..d8a863a --- /dev/null +++ b/data/src/main/java/com/example/data/mapper/MenuMapper.kt @@ -0,0 +1,114 @@ +// data/mapper/MenuMapper.kt +package com.example.data.mapper + +import com.example.data.dto.CategoryDto +import com.example.data.dto.MenuCreateRequest +import com.example.data.dto.MenuDto +import com.example.data.dto.MenuUpdateRequest +import com.example.domain.model.Category +import com.example.domain.model.Menu + +/** + * 메뉴 관련 데이터 변환을 담당하는 매퍼 함수들 + * DTO(Data Transfer Object)와 Domain Entity 간의 변환을 처리 + */ + +/** + * 서버 응답 DTO를 Domain 모델로 변환 + * + * @receiver MenuDto 서버에서 받은 메뉴 데이터 + * @return Menu Domain 계층에서 사용하는 메뉴 모델 + * + * 변환 규칙: + * - menuId → id: 서버의 메뉴 ID를 Domain의 id로 매핑 + * - menuName → name: 서버의 메뉴 이름을 Domain의 name으로 매핑 + * - price → price: 가격 정보 직접 매핑 + * - menuPresent → description: 서버의 메뉴 설명을 Domain의 description으로 매핑 + * - category → categoryId: 서버의 카테고리 ID를 Domain의 categoryId로 매핑 + * - menuImage → imageUrl: 서버의 이미지 URL을 Domain의 imageUrl로 매핑 + */ +fun MenuDto.toDomain(): Menu { + return Menu( + id = menuId, // Long 그대로 사용 + name = menuName, + price = price.toInt(), // Long을 Int로 변환 + description = menuPresent ?: "", // String?을 String으로 안전하게 변환 (null이면 빈 문자열) + imageUrl = menuImage ?: "", // String?을 String으로 안전하게 변환 (null이면 빈 문자열) + categoryId = category, // Long 그대로 사용 + // 나머지 필드들은 기본값 사용 + isAvailable = true, + createdAt = System.currentTimeMillis() + ) +} + +/** + * Domain 모델을 서버 생성 요청 DTO로 변환 + * + * @receiver Menu Domain 계층의 메뉴 모델 + * @param base64Image 업로드할 이미지의 Base64 인코딩 문자열 (선택사항) + * @return MenuCreateRequest 서버에 전송할 메뉴 생성 요청 데이터 + * + * 변환 규칙: + * - categoryId → category: Domain의 categoryId를 서버의 category로 매핑 + * - name → menuName: Domain의 name을 서버의 menuName으로 매핑 + * - price → price: 가격 정보 직접 매핑 + * - description → menuPresent: Domain의 description을 서버의 menuPresent로 매핑 + * - cost: 원가 정보 (현재는 기본값 0 사용) + * - base64Image: 새로 업로드하는 이미지 데이터 + */ +fun Menu.toCreateRequest(base64Image: String?): MenuCreateRequest { + return MenuCreateRequest( + category = categoryId, // Long 그대로 사용 + menuName = name, + price = price.toLong(), // Int를 Long으로 변환 + cost = 0L, // Long 타입으로 기본값 설정 + menuPresent = description, // String을 String으로 그대로 사용 (빈 문자열도 유효) + base64Image = base64Image + ) +} + +/** + * Domain 모델을 서버 수정 요청 DTO로 변환 + * + * @receiver Menu Domain 계층의 메뉴 모델 + * @param base64Image 새로 업로드할 이미지의 Base64 인코딩 문자열 (선택사항, null이면 기존 이미지 유지) + * @return MenuUpdateRequest 서버에 전송할 메뉴 수정 요청 데이터 + * + * 변환 규칙: + * - toCreateRequest와 동일한 매핑 규칙 적용 + * - base64Image가 null이면 서버에서 기존 이미지를 유지함 + */ +fun Menu.toUpdateRequest(base64Image: String?): MenuUpdateRequest { + return MenuUpdateRequest( + category = categoryId, // Long 그대로 사용 + menuName = name, + price = price.toLong(), // Int를 Long으로 변환 + cost = 0L, // Long 타입으로 기본값 설정 + menuPresent = description, // String을 String으로 그대로 사용 + base64Image = base64Image // null이면 기존 이미지 유지 + ) +} + +/** + * 메뉴 리스트를 Domain 모델 리스트로 변환하는 확장 함수 + * + * @receiver List 서버에서 받은 메뉴 DTO 리스트 + * @return List Domain 계층에서 사용하는 메뉴 모델 리스트 + * + * 사용 예: menuDtoList.toMenuDomainList() + */ +fun List.toMenuDomainList(): List { + return this.map { it.toDomain() } +} + +/** + * 카테고리 리스트를 Domain 모델 리스트로 변환하는 확장 함수 + * + * @receiver List 서버에서 받은 카테고리 DTO 리스트 + * @return List Domain 계층에서 사용하는 카테고리 모델 리스트 + * + * 사용 예: categoryDtoList.toCategoryDomainList() + */ +fun List.toCategoryDomainList(): List { + return this.map { it.toDomain() } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/mapper/OrderMapper.kt b/data/src/main/java/com/example/data/mapper/OrderMapper.kt new file mode 100644 index 0000000..ccbc1cb --- /dev/null +++ b/data/src/main/java/com/example/data/mapper/OrderMapper.kt @@ -0,0 +1,32 @@ +package com.example.data.mapper + +import com.example.data.dto.OrderDto +import com.example.domain.model.Order +import com.example.domain.model.OrderStatus + +// DTO -> Domain 변환 +fun OrderDto.toDomain(): Order { + return Order( + orderId = orderId, + storeId = storeId, + orderTime = orderDate, // orderDate -> orderTime으로 매핑 + totalAmount = totalAmount, + status = OrderStatus.fromDisplayName(orderStatus) // orderStatus -> status로 매핑 + ) +} + +// DTO List -> Domain List +fun List.toDomainList(): List { + return this.map { it.toDomain() } +} + +// OrderStatus enum 확장 - API 응답 문자열과 매핑 +fun OrderStatus.Companion.fromDisplayName(displayName: String): OrderStatus { + return when (displayName) { + "string" -> OrderStatus.RECEIVED // API에서 "string"으로 오는 것 같음 + "주문접수" -> OrderStatus.RECEIVED + "완료" -> OrderStatus.COMPLETED + "취소" -> OrderStatus.CANCELLED + else -> OrderStatus.RECEIVED // 기본값 + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/mapper/SalesMapper.kt b/data/src/main/java/com/example/data/mapper/SalesMapper.kt new file mode 100644 index 0000000..bbf16aa --- /dev/null +++ b/data/src/main/java/com/example/data/mapper/SalesMapper.kt @@ -0,0 +1,34 @@ +package com.example.data.mapper + +import com.example.data.dto.SalesDataDto +import com.example.data.dto.TotalSalesDto +import com.example.domain.model.SalesData +import com.example.domain.model.TotalSales + +// data/mapper/SalesMapper.kt +/** + * TotalSalesDto를 TotalSales 도메인 모델로 변환 + */ +fun TotalSalesDto.toDomain(): TotalSales { + return TotalSales( + salesDate = this.salesDate, + totalSales = this.totalSales + ) +} + +/** + * SalesDataDto를 SalesData 도메인 모델로 변환 + */ +fun SalesDataDto.toDomain(): SalesData { + return SalesData( + salesDate = this.salesDate, + totalSales = this.totalSales + ) +} + +/** + * SalesDataDto 리스트를 SalesData 리스트로 변환 + */ +fun List.toDomain(): List { + return this.map { it.toDomain() } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/mapper/StaffMapper.kt b/data/src/main/java/com/example/data/mapper/StaffMapper.kt new file mode 100644 index 0000000..7902aa6 --- /dev/null +++ b/data/src/main/java/com/example/data/mapper/StaffMapper.kt @@ -0,0 +1,62 @@ +// :data/src/main/java/com/example/data/mapper/StaffMapper.kt +package com.example.data.mapper + +import com.example.data.dto.CreateStaffDto +import com.example.data.dto.StaffDto +import com.example.domain.model.Bank +import com.example.domain.model.Position +import com.example.domain.model.Staff + +/** + * StaffDto와 Staff Domain 모델 간 변환 - 실제 서버 스펙 반영 + */ + +// DTO -> Domain +fun StaffDto.toDomain(): Staff { + return Staff( + id = employeeId, + name = employeeName, + phoneNumber = phoneNumber, + hourlyWage = salary, + position = Position.fromDisplayName(position), // 서버의 "요리" -> Position.KITCHEN + bank = Bank.fromAccountPattern(bankAccount), // 계좌번호 패턴으로 은행 추정 + accountNumber = bankAccount, + createdAt = null // 서버에서 제공하지 않음 + ) +} + +// Domain -> CreateStaffDto +fun Staff.toCreateDto(): CreateStaffDto { + return CreateStaffDto( + employeeId = 0, // 생성 시 0 + storeId = 1, // 임시로 1 (나중에 실제 storeId로 변경) + employeeName = name, + phoneNumber = phoneNumber, + salary = hourlyWage, + position = position.displayName, // Position.KITCHEN -> "요리" + bankAccount = accountNumber + ) +} + +// DTO List -> Domain List +fun List.toDomainList(): List { + return this.map { it.toDomain() } +} + +// Position enum 확장 - 서버 응답 문자열과 매핑 +fun Position.Companion.fromDisplayName(displayName: String): Position { + return when (displayName) { + "매니저" -> Position.MANAGER + "바리스타" -> Position.BARISTA + "캐셔" -> Position.CASHIER + "요리" -> Position.KITCHEN + "키오스크매니저" -> Position.KIOSK_MANAGER + else -> Position.BARISTA // 기본값 + } +} + +// Bank enum 확장 - 계좌번호 패턴으로 은행 추정 (임시) +fun Bank.Companion.fromAccountPattern(accountNumber: String): Bank { + // 실제로는 더 정확한 로직 필요, 임시로 기본값 반환 + return Bank.KB // 기본값 +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/repository/MenuRepositoryImpl.kt b/data/src/main/java/com/example/data/repository/MenuRepositoryImpl.kt new file mode 100644 index 0000000..6ad4b95 --- /dev/null +++ b/data/src/main/java/com/example/data/repository/MenuRepositoryImpl.kt @@ -0,0 +1,276 @@ +package com.example.data.repository + +import android.util.Log +import com.example.data.api.CategoryApi +import com.example.data.api.MenuApi +import com.example.data.mapper.toDomain +import com.example.data.mapper.toCategoryDomainList +import com.example.data.mapper.toMenuDomainList +import com.example.data.mapper.toCreateRequest +import com.example.data.mapper.toUpdateRequest +import com.example.domain.model.Menu +import com.example.domain.model.Category +import com.example.domain.repository.MenuRepository +import javax.inject.Inject +import javax.inject.Singleton +import com.example.data.dto.MenuPageResponse +import com.example.data.dto.MenuDto +import retrofit2.Response +/** + * MenuRepository 구현체 + * - 실제 API와 연동하여 메뉴/카테고리 데이터 관리 + * - 기존 임시 데이터에서 실제 API 호출로 변경 + */ +@Singleton +class MenuRepositoryImpl @Inject constructor( + // 실제 API 서비스 주입 + private val menuApi: MenuApi, + private val categoryApi: CategoryApi +) : MenuRepository { + + companion object { + private const val TAG = "MenuRepositoryImpl" + } + + /** + * 모든 카테고리 조회 + * 기존: 임시 데이터 반환 → 변경: 실제 API 호출 + */ + override suspend fun getCategories(): Result> { + return try { + Log.d(TAG, "🔄 카테고리 목록 조회 시작") + + // 실제 API 호출 + val response = categoryApi.getCategories() + Log.d(TAG, "📡 카테고리 API 응답 코드: ${response.code()}") + Log.d(TAG, "📡 카테고리 API 응답 성공 여부: ${response.isSuccessful}") + + if (response.isSuccessful) { + val body = response.body() + Log.d(TAG, "✅ 카테고리 응답 성공: ${body?.size}개 항목") + Log.d(TAG, "📦 원본 카테고리 데이터: $body") + + val categories = body?.toCategoryDomainList() ?: emptyList() + Log.d(TAG, "🔄 도메인 변환 완료: ${categories.size}개") + Log.d(TAG, "🏆 최종 카테고리 데이터: $categories") + + Result.success(categories) + } else { + val errorBody = response.errorBody()?.string() + val error = "카테고리 조회 실패: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $error") + Log.e(TAG, "❌ 에러 바디: $errorBody") + Result.failure(Exception(error)) + } + } catch (e: Exception) { + val error = "카테고리 조회 중 네트워크 오류: ${e.message}" + Log.e(TAG, "💥 $error", e) + Result.failure(Exception(error)) + } + } + + /** + * 새 카테고리 추가 + * 기존: 임시 카테고리 생성 → 변경: 실제 API 호출 + */ + /** + * 새 카테고리 추가 + * 기존: 임시 카테고리 생성 → 변경: 실제 API 호출 + */ + override suspend fun addCategory(name: String): Result { + return try { + Log.d(TAG, "📁 카테고리 추가 시작: $name") + + // 임시 ID 생성 (서버에서 실제 ID 할당) + val tempCategory = Category( + id = 0, // 서버에서 할당받을 예정 + name = name, + order = 999, // 임시 순서 + isDefault = false, + menuCount = 0 + ) + + val request = tempCategory.toCreateRequest() + Log.d(TAG, "📁 요청 데이터: $request") + + val response = categoryApi.createCategory(request) + Log.d(TAG, "📁 서버 응답 코드: ${response.code()}") + Log.d(TAG, "📁 서버 응답 메시지: ${response.message()}") + + if (response.isSuccessful) { + val createdCategory = response.body()?.toDomain() + ?: throw Exception("서버 응답이 비어있습니다") + Log.d(TAG, "✅ 카테고리 생성 성공: $createdCategory") + Result.success(createdCategory) + } else { + // 에러 바디도 확인 + val errorBody = response.errorBody()?.string() + Log.e(TAG, "❌ 카테고리 생성 실패") + Log.e(TAG, "❌ 응답 코드: ${response.code()}") + Log.e(TAG, "❌ 응답 메시지: ${response.message()}") + Log.e(TAG, "❌ 에러 바디: $errorBody") + + Result.failure(Exception("카테고리 생성 실패: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Log.e(TAG, "💥 카테고리 생성 중 예외 발생: ${e.message}", e) + Result.failure(Exception("카테고리 생성 중 네트워크 오류: ${e.message}")) + } + } + + /** + * 카테고리 삭제 + * 기존: 성공만 반환 → 변경: 실제 API 호출 + */ + override suspend fun deleteCategory(categoryId: Long): Result { + return try { + // 실제 API 호출 + val response = categoryApi.deleteCategory(categoryId) + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("카테고리 삭제 실패: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Result.failure(Exception("카테고리 삭제 중 네트워크 오류: ${e.message}")) + } + } + + /** + * 카테고리 순서 업데이트 + * 현재: API에 해당 엔드포인트가 없어서 주석 처리 + * TODO: 백엔드에 카테고리 순서 변경 API 추가 시 구현 + */ + override suspend fun updateCategoryOrder(categories: List): Result { + return try { + // TODO: 실제 API 호출 (현재 API에 해당 엔드포인트 없음) + // for (category in categories) { + // categoryApi.updateCategoryOrder(category.id, category.order) + // } + + // 현재는 성공으로 처리 (임시) + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * 특정 카테고리의 메뉴들 조회 + * 기존: 임시 데이터 필터링 → 변경: 실제 API 호출 + */ + override suspend fun getMenusByCategory(categoryId: Long): Result> { + return try { + val response = menuApi.getMenus(page = 0, size = 100, category = categoryId) + + if (response.isSuccessful) { + val pageResponse: MenuPageResponse? = response.body() + val content: List? = pageResponse?.content + val menus = content?.toMenuDomainList() ?: emptyList() + Result.success(menus) + } else { + Result.failure(Exception("카테고리별 메뉴 조회 실패: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(Exception("네트워크 오류: ${e.message}")) + } + } + + /** + * 모든 메뉴 조회 + * 기존: 임시 데이터 반환 → 변경: 실제 API 호출 + */ + override suspend fun getAllMenus(): Result> { + return try { + val response = menuApi.getMenus(page = 0, size = 100, category = null) + + if (response.isSuccessful) { + val pageResponse: MenuPageResponse? = response.body() + val content: List? = pageResponse?.content + val menus = content?.toMenuDomainList() ?: emptyList() + Result.success(menus) + } else { + Result.failure(Exception("메뉴 목록 조회 실패: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(Exception("메뉴 조회 중 네트워크 오류: ${e.message}")) + } + } + + /** + * 새 메뉴 추가 + * 기존: 임시 ID 할당 → 변경: 실제 API 호출 + */ + override suspend fun addMenu(menu: Menu, base64Image: String?): Result { // = null 제거 + return try { + Log.d(TAG, "📱 메뉴 추가 시작: ${menu.name}") + Log.d(TAG, "🖼️ Base64 이미지: ${base64Image?.take(50) ?: "없음"}...") + + // base64Image를 실제로 전달 + val request = menu.toCreateRequest(base64Image = base64Image) + val response = menuApi.createMenu(request) + + if (response.isSuccessful) { + val createdMenu = response.body()?.toDomain() + ?: throw Exception("서버 응답이 비어있습니다") + Log.d(TAG, "✅ 메뉴 생성 성공: ${createdMenu.name}") + Result.success(createdMenu) + } else { + val error = "메뉴 생성 실패: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $error") + Result.failure(Exception(error)) + } + } catch (e: Exception) { + val error = "메뉴 생성 중 네트워크 오류: ${e.message}" + Log.e(TAG, "💥 $error", e) + Result.failure(Exception(error)) + } + } + + /** + * 메뉴 정보 수정 + * 기존: 임시 리스트 수정 → 변경: 실제 API 호출 + */ + override suspend fun updateMenu(menu: Menu): Result { + return try { + // base64Image 없이 메뉴 수정 (임시) + val request = menu.toUpdateRequest(base64Image = null) + val response = menuApi.updateMenu(menu.id, request) + + if (response.isSuccessful) { + val updatedMenu = response.body()?.toDomain() + ?: throw Exception("서버 응답이 비어있습니다") + Result.success(updatedMenu) + } else { + Result.failure(Exception("메뉴 수정 실패: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Result.failure(Exception("메뉴 수정 중 네트워크 오류: ${e.message}")) + } + } + + /** + * 메뉴 삭제 + * 기존: 임시 리스트에서 제거 → 변경: 실제 API 호출 + */ + override suspend fun deleteMenu(menuId: Long): Result { + return try { + // 실제 API 호출 + val response = menuApi.deleteMenu(menuId) + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("메뉴 삭제 실패: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Result.failure(Exception("메뉴 삭제 중 네트워크 오류: ${e.message}")) + } + } + + // TODO: 이미지 업로드 기능을 위한 추가 메서드들 + // 향후 Repository 인터페이스에 추가 필요: + // suspend fun addMenuWithImage(menu: Menu, base64Image: String): Result + // suspend fun updateMenuWithImage(menu: Menu, base64Image: String?): Result +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/repository/OrderRepositoryImpl.kt b/data/src/main/java/com/example/data/repository/OrderRepositoryImpl.kt new file mode 100644 index 0000000..ec9265d --- /dev/null +++ b/data/src/main/java/com/example/data/repository/OrderRepositoryImpl.kt @@ -0,0 +1,81 @@ +package com.example.data.repository + +import android.util.Log +import com.example.data.api.OrderApi +import com.example.data.mapper.toDomain +import com.example.data.mapper.toDomainList +import com.example.domain.model.Order +import com.example.domain.repository.OrderRepository + + + +class OrderRepositoryImpl( + private val api: OrderApi +) : OrderRepository { + + override suspend fun getAllOrders(storeId: Int): Result> { + return try { + Log.d("OrderRepository", "🔍 매장 $storeId 주문 목록 조회 시작") + val response = api.getAllOrders(storeId) + + if (response.isSuccessful) { + val orderDtos = response.body() ?: emptyList() + Log.d("OrderRepository", "📡 서버 응답 성공: ${orderDtos.size}개 주문") + Log.d("OrderRepository", "📊 응답 데이터: $orderDtos") + + val orders = orderDtos.toDomainList() + Log.d("OrderRepository", "✅ 도메인 변환 완료: $orders") + Result.success(orders) + } else { + Log.e("OrderRepository", "❌ API 응답 실패: ${response.code()}") + Result.failure(Exception("주문 목록 조회 실패: ${response.code()}")) + } + } catch (e: Exception) { + Log.e("OrderRepository", "❌ 네트워크 에러: ${e.message}") + Result.failure(e) + } + } + + override suspend fun deleteOrder(orderId: Int): Result { + return try { + Log.d("OrderRepository", "🗑️ 주문 삭제 시작: ID $orderId") + val response = api.deleteOrder(orderId) + + if (response.isSuccessful) { + Log.d("OrderRepository", "✅ 주문 삭제 성공: ID $orderId") + Result.success(Unit) + } else { + Log.e("OrderRepository", "❌ 주문 삭제 실패: ${response.code()}") + Result.failure(Exception("주문 삭제 실패: ${response.code()}")) + } + } catch (e: Exception) { + Log.e("OrderRepository", "❌ 삭제 중 에러: ${e.message}") + Result.failure(e) + } + } + + override suspend fun getOrder(orderId: Int): Result { + return try { + Log.d("OrderRepository", "🔍 단일 주문 조회 시작: ID $orderId") + val response = api.getOrder(orderId) + + if (response.isSuccessful) { + val orderDto = response.body() + if (orderDto != null) { + Log.d("OrderRepository", "✅ 주문 조회 성공: $orderDto") + val order = orderDto.toDomain() + Result.success(order) + } else { + Log.e("OrderRepository", "❌ 주문 데이터 null") + Result.failure(Exception("주문 데이터가 없습니다")) + } + } else { + Log.e("OrderRepository", "❌ 주문 조회 실패: ${response.code()}") + Result.failure(Exception("주문 조회 실패: ${response.code()}")) + } + } catch (e: Exception) { + Log.e("OrderRepository", "❌ 주문 조회 에러: ${e.message}") + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/repository/SalesRepositoryImpl.kt b/data/src/main/java/com/example/data/repository/SalesRepositoryImpl.kt new file mode 100644 index 0000000..ced9b19 --- /dev/null +++ b/data/src/main/java/com/example/data/repository/SalesRepositoryImpl.kt @@ -0,0 +1,78 @@ +package com.example.data.repository + +// data/repository/SalesRepositoryImpl.kt + + +import android.util.Log +import com.example.data.api.SalesApi +import com.example.data.mapper.toDomain +import com.example.domain.model.SalesData +import com.example.domain.model.TotalSales +import com.example.domain.repository.SalesRepository +import javax.inject.Inject + +/** + * 매출 데이터 리포지토리 구현체 + */ +class SalesRepositoryImpl @Inject constructor( + private val salesApi: SalesApi +) : SalesRepository { + + companion object { + private const val TAG = "SalesRepository" + } + + override suspend fun getTotalSales(): Result { + return try { + Log.d(TAG, "🔍 총 매출 조회 시작") + + val response = salesApi.getTotalSales() + Log.d(TAG, "📡 총 매출 응답: $response") + + val totalSales = response.toDomain() + Log.d(TAG, "✅ 총 매출 변환 성공: $totalSales") + + Result.success(totalSales) + } catch (e: Exception) { + Log.e(TAG, "❌ 총 매출 조회 실패", e) + Result.failure(e) + } + } + + override suspend fun getYearlySales(): Result> { + return try { + Log.d(TAG, "🔍 연도별 매출 조회 시작") + + val response = salesApi.getYearlySales() + Log.d(TAG, "📡 연도별 매출 응답: ${response.size}개 데이터") + + val yearlySales = response.toDomain() + Log.d(TAG, "✅ 연도별 매출 변환 성공: ${yearlySales.size}개") + + Result.success(yearlySales) + } catch (e: Exception) { + Log.e(TAG, "❌ 연도별 매출 조회 실패", e) + Result.failure(e) + } + } + + override suspend fun getMonthlySales(): Result> { + return try { + Log.d(TAG, "🔍 월별 매출 조회 시작") + + val response = salesApi.getMonthlySales() + Log.d(TAG, "📡 월별 매출 응답: ${response.size}개 데이터") + response.forEach { data -> + Log.d(TAG, "📊 월별 데이터: ${data.salesDate} - ${data.totalSales}원") + } + + val monthlySales = response.toDomain() + Log.d(TAG, "✅ 월별 매출 변환 성공: ${monthlySales.size}개") + + Result.success(monthlySales) + } catch (e: Exception) { + Log.e(TAG, "❌ 월별 매출 조회 실패", e) + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/com/example/data/repository/StaffRepositoryImpl.kt b/data/src/main/java/com/example/data/repository/StaffRepositoryImpl.kt new file mode 100644 index 0000000..006282b --- /dev/null +++ b/data/src/main/java/com/example/data/repository/StaffRepositoryImpl.kt @@ -0,0 +1,265 @@ +// :data/src/main/java/com/example/data/repository/StaffRepositoryImpl.kt +package com.example.data.repository + +import android.util.Log +import com.example.data.api.StaffApi +import com.example.data.mapper.toCreateDto +import com.example.data.mapper.toDomain +import com.example.data.mapper.toDomainList +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StaffRepositoryImpl @Inject constructor( + private val staffApi: StaffApi +) : StaffRepository { + + companion object { + private const val TAG = "StaffRepository" + } + + // 로컬 캐시 (API 호출 후 결과 저장) + private val _staffList = MutableStateFlow>(emptyList()) + private val staffListFlow = _staffList.asStateFlow() + + override suspend fun getStaffList(): Result> { + Log.d(TAG, "🔍 직원 목록 조회 시작") + return try { + val response = staffApi.getAllEmployees() + Log.d(TAG, "📡 API 응답: ${response.code()} - ${response.message()}") + + if (response.isSuccessful) { + val responseBody = response.body() + Log.d(TAG, "📊 응답 데이터: $responseBody") + + val staffList = responseBody?.toDomainList() ?: emptyList() + Log.d(TAG, "✅ 변환된 직원 수: ${staffList.size}") + staffList.forEach { staff -> + Log.d(TAG, "👤 직원: ${staff.name} (ID: ${staff.id})") + } + + _staffList.value = staffList + Result.success(staffList) + } else { + val errorMsg = "서버 오류: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $errorMsg") + Log.e(TAG, "❌ 에러 바디: ${response.errorBody()?.string()}") + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override suspend fun getStaffById(id: Long): Result { + Log.d(TAG, "🔍 직원 상세 조회 시작 - ID: $id") + return try { + val response = staffApi.getEmployeeById(id) + Log.d(TAG, "📡 API 응답: ${response.code()} - ${response.message()}") + + if (response.isSuccessful) { + val responseBody = response.body() + Log.d(TAG, "📊 응답 데이터: $responseBody") + + val staff = responseBody?.toDomain() + if (staff != null) { + Log.d(TAG, "✅ 직원 조회 성공: ${staff.name}") + Result.success(staff) + } else { + Log.e(TAG, "❌ 응답 바디가 null") + Result.failure(Exception("직원을 찾을 수 없습니다.")) + } + } else { + val errorMsg = "서버 오류: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $errorMsg") + Log.e(TAG, "❌ 에러 바디: ${response.errorBody()?.string()}") + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override suspend fun addStaff(staff: Staff): Result { + Log.d(TAG, "➕ 직원 추가 시작 - 이름: ${staff.name}") + return try { + val createDto = staff.toCreateDto() + Log.d(TAG, "📤 전송할 데이터: $createDto") + + val response = staffApi.createEmployee(createDto) + Log.d(TAG, "📡 API 응답: ${response.code()} - ${response.message()}") + + if (response.isSuccessful) { + val responseBody = response.body() + Log.d(TAG, "📊 응답 데이터: $responseBody") + + val newStaff = responseBody?.toDomain() + if (newStaff != null) { + Log.d(TAG, "✅ 직원 추가 성공: ${newStaff.name} (ID: ${newStaff.id})") + + // 로컬 캐시 업데이트 + val updatedList = _staffList.value + newStaff + _staffList.value = updatedList + Log.d(TAG, "📝 로컬 캐시 업데이트 완료 - 총 ${updatedList.size}명") + + Result.success(newStaff) + } else { + Log.e(TAG, "❌ 응답 바디가 null") + Result.failure(Exception("직원 추가에 실패했습니다.")) + } + } else { + val errorMsg = "서버 오류: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $errorMsg") + Log.e(TAG, "❌ 에러 바디: ${response.errorBody()?.string()}") + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override suspend fun updateStaff(staff: Staff): Result { + Log.d(TAG, "✏️ 직원 수정 시작 - 이름: ${staff.name} (ID: ${staff.id})") + Log.w(TAG, "⚠️ 서버에 UPDATE API가 없어서 로컬 업데이트만 진행") + + return try { + val updatedList = _staffList.value.map { + if (it.id == staff.id) staff else it + } + _staffList.value = updatedList + Log.d(TAG, "✅ 로컬 직원 정보 수정 완료") + Result.success(staff) + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override suspend fun deleteStaff(id: Long): Result { + Log.d(TAG, "🗑️ 직원 삭제 시작 - ID: $id") + return try { + val response = staffApi.deleteEmployee(id) + Log.d(TAG, "📡 API 응답: ${response.code()} - ${response.message()}") + + if (response.isSuccessful) { + Log.d(TAG, "✅ 서버에서 직원 삭제 성공") + + // 로컬 캐시에서도 제거 + val beforeSize = _staffList.value.size + val updatedList = _staffList.value.filter { it.id != id } + _staffList.value = updatedList + + Log.d(TAG, "📝 로컬 캐시 업데이트: ${beforeSize}명 → ${updatedList.size}명") + Result.success(Unit) + } else { + val errorMsg = "서버 오류: ${response.code()} - ${response.message()}" + Log.e(TAG, "❌ $errorMsg") + Log.e(TAG, "❌ 에러 바디: ${response.errorBody()?.string()}") + Result.failure(Exception(errorMsg)) + } + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override suspend fun searchStaff(query: String): Result> { + Log.d(TAG, "🔍 직원 검색 시작 - 검색어: '$query'") + return try { + // 서버에 검색 API가 없으므로 전체 조회 후 로컬 필터링 + Log.d(TAG, "📡 서버에 검색 API가 없어서 전체 조회 후 필터링") + val result = getStaffList() + + if (result.isSuccess) { + val allStaff = result.getOrNull() ?: emptyList() + Log.d(TAG, "📊 전체 직원 수: ${allStaff.size}") + + val searchResults = if (query.isBlank()) { + Log.d(TAG, "🔍 검색어 없음 - 전체 결과 반환") + allStaff + } else { + val filtered = allStaff.filter { staff -> + staff.name.contains(query, ignoreCase = true) || + staff.phoneNumber.contains(query) + } + Log.d(TAG, "🔍 검색 결과: ${filtered.size}명") + filtered.forEach { staff -> + Log.d(TAG, "👤 검색된 직원: ${staff.name}") + } + filtered + } + Result.success(searchResults) + } else { + Log.e(TAG, "❌ 전체 직원 조회 실패") + result + } + } catch (e: Exception) { + Log.e(TAG, "💥 예외 발생: ${e.message}", e) + Result.failure(e) + } + } + + override fun observeStaffList(): Flow> { + Log.d(TAG, "👀 직원 목록 관찰 시작") + return staffListFlow + } +} +// // 임시 샘플 데이터 생성 +// private fun generateSampleStaffData(): List { +// return listOf( +// Staff( +// id = 1, +// name = "임준식", +// phoneNumber = "01012345678", +// hourlyWage = 12500, +// position = Position.MANAGER, +// bank = Bank.WOORI, +// accountNumber = "123-891-774411" +// ), +// Staff( +// id = 2, +// name = "김민지", +// phoneNumber = "01098765432", +// hourlyWage = 10000, +// position = Position.BARISTA, +// bank = Bank.KB, +// accountNumber = "987-654-321012" +// ), +// Staff( +// id = 3, +// name = "박지현", +// phoneNumber = "01045678912", +// hourlyWage = 9800, +// position = Position.CASHIER, +// bank = Bank.SHINHAN, +// accountNumber = "456-789-123456" +// ), +// Staff( +// id = 4, +// name = "이승우", +// phoneNumber = "01033445566", +// hourlyWage = 11000, +// position = Position.KITCHEN, +// bank = Bank.HANA, +// accountNumber = "334-455-667788" +// ), +// Staff( +// id = 5, +// name = "정다은", +// phoneNumber = "01077889900", +// hourlyWage = 12500, +// position = Position.KIOSK_MANAGER, +// bank = Bank.NH, +// accountNumber = "778-899-001122" +// ) +// ) +// } +//} \ No newline at end of file diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 914971f..008fb79 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -1,7 +1,26 @@ plugins { - id("barrion.jvm.library") + id("java-library") + id("org.jetbrains.kotlin.jvm") } +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + } +} dependencies { - // 모듈 특화 의존성만 추가 + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + + // Dependency Injection (순수 자바 버전) + implementation("javax.inject:javax.inject:1") + + // 테스트 의존성 + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") } \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/MyClass.kt b/domain/src/main/java/com/example/domain/MyClass.kt deleted file mode 100644 index 446d8b1..0000000 --- a/domain/src/main/java/com/example/domain/MyClass.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.domain - -class MyClass { -} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/Category.kt b/domain/src/main/java/com/example/domain/model/Category.kt new file mode 100644 index 0000000..330302f --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/Category.kt @@ -0,0 +1,15 @@ +package com.example.domain.model + +/** + * 카테고리 도메인 엔티티 + * - 메뉴들을 그룹화하는 카테고리 정보 + * - 순서(order)를 통해 화면 표시 순서 관리 + */ +data class Category( + val id: Long = 0L, // 카테고리 고유 ID + val name: String, // 카테고리 이름 (예: "추천", "커피") + val order: Int, // 표시 순서 (1, 2, 3, 4...) + val isDefault: Boolean = false, // 기본 카테고리 여부 (삭제 불가) + val menuCount: Int = 0, // 해당 카테고리의 메뉴 개수 + val createdAt: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/Menu.kt b/domain/src/main/java/com/example/domain/model/Menu.kt new file mode 100644 index 0000000..6083662 --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/Menu.kt @@ -0,0 +1,17 @@ +package com.example.domain.model + +/** + * 메뉴 도메인 엔티티 + * - 비즈니스 로직에서 사용하는 순수한 데이터 모델 + * - UI나 API에 의존하지 않음 + */ +data class Menu( + val id: Long = 0L, // 메뉴 고유 ID (서버에서 생성) + val name: String, // 메뉴 이름 (예: "시그니처 커피") + val price: Int, // 가격 (정수로 저장, 원 단위) + val description: String = "", // 메뉴 설명 (선택사항) + val imageUrl: String = "", // 이미지 URL (Base64 또는 서버 URL) + val categoryId: Long, // 소속 카테고리 ID + val isAvailable: Boolean = true, // 판매 가능 여부 + val createdAt: Long = System.currentTimeMillis() // 생성 시간 +) \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/Order.kt b/domain/src/main/java/com/example/domain/model/Order.kt new file mode 100644 index 0000000..898ffb6 --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/Order.kt @@ -0,0 +1,10 @@ +package com.example.domain.model + +// domain/model/Order.kt +data class Order( + val orderId: Int, + val storeId: Int, + val orderTime: String, // "2025.05.09 10:25" 형식 + val totalAmount: Int, + val status: OrderStatus +) \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/OrderStatus.kt b/domain/src/main/java/com/example/domain/model/OrderStatus.kt new file mode 100644 index 0000000..9278752 --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/OrderStatus.kt @@ -0,0 +1,11 @@ +package com.example.domain.model + +// domain/model/OrderStatus.kt + +enum class OrderStatus(val displayName: String, val colorType: String) { + RECEIVED("주문접수", "blue"), + COMPLETED("완료", "gray"), + CANCELLED("취소", "red"); + + companion object +} diff --git a/domain/src/main/java/com/example/domain/model/OrderSummary.kt b/domain/src/main/java/com/example/domain/model/OrderSummary.kt new file mode 100644 index 0000000..5095407 --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/OrderSummary.kt @@ -0,0 +1,8 @@ +package com.example.domain.model + +// domain/model/OrderSummary.kt +data class OrderSummary( + val completedCount: Int, + val totalCount: Int, + val totalAmount: Int +) \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/Sales.kt b/domain/src/main/java/com/example/domain/model/Sales.kt new file mode 100644 index 0000000..8ecb86b --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/Sales.kt @@ -0,0 +1,96 @@ +package com.example.domain.model + +// domain/model/Sales.kt + +/** + * 매출 데이터 도메인 모델 + */ +data class SalesData( + val salesDate: String, + val totalSales: Long +) + +/** + * 총 매출 정보 + */ +data class TotalSales( + val salesDate: String, + val totalSales: Long +) + +/** + * 매출 요약 정보 (UI에서 계산된 데이터) + */ +data class SalesSummary( + val totalSales: Long, + val averageMonthlySales: Long, + val highestSalesMonth: Int, + val lowestSalesMonth: Int, + val highestSalesAmount: Long, + val lowestSalesAmount: Long +) { + companion object { + fun fromMonthlySales(monthlySales: List): SalesSummary { + if (monthlySales.isEmpty()) { + return SalesSummary(0, 0, 1, 1, 0, 0) + } + + val total = monthlySales.sumOf { it.totalSales } + val average = total / monthlySales.size + val maxSales = monthlySales.maxByOrNull { it.totalSales } + val minSales = monthlySales.minByOrNull { it.totalSales } + + return SalesSummary( + totalSales = total, + averageMonthlySales = average, + highestSalesMonth = extractMonth(maxSales?.salesDate ?: ""), + lowestSalesMonth = extractMonth(minSales?.salesDate ?: ""), + highestSalesAmount = maxSales?.totalSales ?: 0, + lowestSalesAmount = minSales?.totalSales ?: 0 + ) + } + + private fun extractMonth(dateString: String): Int { + return try { + // "2025-05-01T00:00:00" 형식에서 월 추출 + dateString.substring(5, 7).toInt() + } catch (e: Exception) { + 1 + } + } + } +} + +/** + * 차트용 데이터 모델 + */ +data class ChartData( + val month: Int, + val sales: Long, + val isHighlight: Boolean = false +) { + companion object { + fun fromMonthlySales(monthlySales: List, highlightMonth: Int? = null): List { + // 1월부터 12월까지 모든 월 데이터 생성 + val monthlyMap = monthlySales.associateBy { + extractMonth(it.salesDate) + } + + return (1..12).map { month -> + ChartData( + month = month, + sales = monthlyMap[month]?.totalSales ?: 0, + isHighlight = month == highlightMonth + ) + } + } + + private fun extractMonth(dateString: String): Int { + return try { + dateString.substring(5, 7).toInt() + } catch (e: Exception) { + 1 + } + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/model/Staff.kt b/domain/src/main/java/com/example/domain/model/Staff.kt new file mode 100644 index 0000000..e29cd0e --- /dev/null +++ b/domain/src/main/java/com/example/domain/model/Staff.kt @@ -0,0 +1,63 @@ +// :domain/src/main/java/com/example/domain/model/Staff.kt +package com.example.domain.model + +/** + * 직원 도메인 모델 + */ +data class Staff( + val id: Long, + val name: String, + val phoneNumber: String, + val hourlyWage: Int, + val position: Position, + val bank: Bank, + val accountNumber: String, + val createdAt: String? = null +) + +/** + * 직무/역할 enum + */ +enum class Position(val code: String, val displayName: String) { + MANAGER("MANAGER", "매니저"), + BARISTA("BARISTA", "바리스타"), + CASHIER("CASHIER", "캐셔"), + KITCHEN("KITCHEN", "주방보조"), + KIOSK_MANAGER("KIOSK_MANAGER", "키오스크관리"); + + companion object { + fun fromCode(code: String): Position { + return values().find { it.code == code } ?: BARISTA + } + + fun getAllDisplayNames(): List { + return values().map { it.displayName } + } + } +} + +/** + * 은행 enum + */ +enum class Bank(val code: String, val displayName: String) { + WOORI("WOORI", "우리"), + KB("KB", "국민"), + SHINHAN("SHINHAN", "신한"), + HANA("HANA", "하나"), + NH("NH", "농협"), + IBK("IBK", "기업"), + BUSAN("BUSAN", "부산"), + DAEGU("DAEGU", "대구"), + KWANGJU("KWANGJU", "광주"), + JEONBUK("JEONBUK", "전북"); + + companion object { + fun fromCode(code: String): Bank { + return values().find { it.code == code } ?: WOORI + } + + fun getAllDisplayNames(): List { + return values().map { it.displayName } + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/repository/MenuRepository.kt b/domain/src/main/java/com/example/domain/repository/MenuRepository.kt new file mode 100644 index 0000000..d762337 --- /dev/null +++ b/domain/src/main/java/com/example/domain/repository/MenuRepository.kt @@ -0,0 +1,25 @@ +package com.example.domain.repository + +import com.example.domain.model.Category +import com.example.domain.model.Menu + +/** + * 메뉴 관련 데이터 접근을 위한 Repository 인터페이스 + * - Domain 계층에서 정의, Data 계층에서 구현 + * - 비즈니스 로직은 이 인터페이스에만 의존 + */ +interface MenuRepository { + + // 카테고리 관련 + suspend fun getCategories(): Result> + suspend fun addCategory(name: String): Result + suspend fun deleteCategory(categoryId: Long): Result + suspend fun updateCategoryOrder(categories: List): Result + + // 메뉴 관련 + suspend fun getMenusByCategory(categoryId: Long): Result> + suspend fun getAllMenus(): Result> + suspend fun addMenu(menu: Menu, base64Image: String? = null): Result + suspend fun updateMenu(menu: Menu): Result + suspend fun deleteMenu(menuId: Long): Result +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/repository/OrderRepository.kt b/domain/src/main/java/com/example/domain/repository/OrderRepository.kt new file mode 100644 index 0000000..1b1ac44 --- /dev/null +++ b/domain/src/main/java/com/example/domain/repository/OrderRepository.kt @@ -0,0 +1,9 @@ +package com.example.domain.repository + +import com.example.domain.model.Order + +interface OrderRepository { + suspend fun getAllOrders(storeId: Int): Result> + suspend fun deleteOrder(orderId: Int): Result + suspend fun getOrder(orderId: Int): Result +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/repository/SalesRepository.kt b/domain/src/main/java/com/example/domain/repository/SalesRepository.kt new file mode 100644 index 0000000..f63a1d7 --- /dev/null +++ b/domain/src/main/java/com/example/domain/repository/SalesRepository.kt @@ -0,0 +1,26 @@ +package com.example.domain.repository +// domain/repository/SalesRepository.kt + +import com.example.domain.model.SalesData +import com.example.domain.model.TotalSales + +/** + * 매출 데이터 리포지토리 인터페이스 + */ +interface SalesRepository { + + /** + * 총 매출 조회 + */ + suspend fun getTotalSales(): Result + + /** + * 연도별 매출 조회 + */ + suspend fun getYearlySales(): Result> + + /** + * 월별 매출 조회 + */ + suspend fun getMonthlySales(): Result> +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/repository/StaffRepository.kt b/domain/src/main/java/com/example/domain/repository/StaffRepository.kt new file mode 100644 index 0000000..1606bee --- /dev/null +++ b/domain/src/main/java/com/example/domain/repository/StaffRepository.kt @@ -0,0 +1,46 @@ +// :domain/src/main/java/com/example/domain/repository/StaffRepository.kt +package com.example.domain.repository + +import com.example.domain.model.Staff +import kotlinx.coroutines.flow.Flow + +/** + * 직원 데이터 저장소 인터페이스 + */ +interface StaffRepository { + + /** + * 모든 직원 목록 조회 + */ + suspend fun getStaffList(): Result> + + /** + * 특정 직원 상세 정보 조회 + */ + suspend fun getStaffById(id: Long): Result + + /** + * 새 직원 추가 + */ + suspend fun addStaff(staff: Staff): Result + + /** + * 직원 정보 수정 + */ + suspend fun updateStaff(staff: Staff): Result + + /** + * 직원 삭제 + */ + suspend fun deleteStaff(id: Long): Result + + /** + * 이름 또는 전화번호로 직원 검색 + */ + suspend fun searchStaff(query: String): Result> + + /** + * 직원 목록 실시간 관찰 (선택사항) + */ + fun observeStaffList(): Flow> +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/menu/AddCategoryUseCase.kt b/domain/src/main/java/com/example/domain/usecase/menu/AddCategoryUseCase.kt new file mode 100644 index 0000000..69f4925 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/menu/AddCategoryUseCase.kt @@ -0,0 +1,34 @@ +package com.example.domain.usecase.menu + +import com.example.domain.model.Category +import com.example.domain.repository.MenuRepository +import javax.inject.Inject + +/** + * 카테고리 추가 UseCase + * - 카테고리 추가 시 필요한 비즈니스 로직 처리 + * - 입력 데이터 검증 + */ +class AddCategoryUseCase @Inject constructor( + private val menuRepository: MenuRepository +) { + + /** + * 새 카테고리를 추가 + * @param name 카테고리 이름 + */ + suspend fun execute(name: String): Result { + + // 1. 입력 데이터 검증 + if (name.isBlank()) { + return Result.failure(IllegalArgumentException("카테고리 이름은 필수입니다")) + } + + if (name.length > 20) { + return Result.failure(IllegalArgumentException("카테고리 이름은 20자 이하로 입력해주세요")) + } + + // 2. 카테고리 추가 + return menuRepository.addCategory(name.trim()) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/menu/AddMenuUseCase.kt b/domain/src/main/java/com/example/domain/usecase/menu/AddMenuUseCase.kt new file mode 100644 index 0000000..27cbd9d --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/menu/AddMenuUseCase.kt @@ -0,0 +1,60 @@ +package com.example.domain.usecase.menu + +import com.example.domain.model.Menu +import com.example.domain.repository.MenuRepository +import javax.inject.Inject + +/** + * 메뉴 추가 UseCase + * - 메뉴 추가 시 필요한 비즈니스 로직 처리 + * - 입력 데이터 검증 및 메뉴 생성 + */ +class AddMenuUseCase @Inject constructor( + private val menuRepository: MenuRepository +) { + + /** + * 새 메뉴를 추가 + * @param name 메뉴 이름 + * @param price 가격 + * @param categoryId 카테고리 ID + * @param description 설명 (선택사항) + * @param imageUrl 이미지 URL (선택사항) + * @param base64Image Base64 인코딩된 이미지 데이터 (선택사항) + */ + suspend fun execute( + name: String, + price: Int, + categoryId: Long, + description: String = "", + imageUrl: String = "", + base64Image: String? = null // 추가 + ): Result { + + // 1. 입력 데이터 검증 + if (name.isBlank()) { + return Result.failure(IllegalArgumentException("메뉴 이름은 필수입니다")) + } + + if (price < 0) { + return Result.failure(IllegalArgumentException("가격은 0 이상이어야 합니다")) + } + + if (categoryId <= 0) { + return Result.failure(IllegalArgumentException("올바른 카테고리를 선택해주세요")) + } + + // 2. 메뉴 객체 생성 + val newMenu: Menu = Menu( + name = name.trim(), + price = price, + categoryId = categoryId, + description = description.trim(), + imageUrl = imageUrl + ) + + // 3. 저장 (base64Image도 함께 전달) + // 참고: Repository의 addMenu 메서드도 base64Image 파라미터가 필요함 + return menuRepository.addMenu(newMenu, base64Image) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/menu/DeleteMenuUseCase.kt b/domain/src/main/java/com/example/domain/usecase/menu/DeleteMenuUseCase.kt new file mode 100644 index 0000000..766797e --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/menu/DeleteMenuUseCase.kt @@ -0,0 +1,36 @@ +package com.example.domain.usecase.menu + +import com.example.domain.repository.MenuRepository +import javax.inject.Inject + +/** + * 메뉴 삭제 UseCase + * - 메뉴 삭제 시 필요한 비즈니스 로직 처리 + * - 삭제 전 검증 로직 포함 + */ + +// 다른 UseCase 코드와 동일 +class DeleteMenuUseCase @Inject constructor( + private val menuRepository: MenuRepository +) { + + /** + * 메뉴를 삭제 + * @param menuId 삭제할 메뉴 ID + * @return 삭제 결과 + */ + suspend fun execute(menuId: Long): Result { + + // 1. 입력 데이터 검증 + if (menuId <= 0) { + return Result.failure(IllegalArgumentException("올바른 메뉴 ID가 아닙니다")) + } + + // 2. 메뉴 삭제 실행 + return try { + menuRepository.deleteMenu(menuId) + } catch (e: Exception) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/menu/GetMenusUseCase.kt b/domain/src/main/java/com/example/domain/usecase/menu/GetMenusUseCase.kt new file mode 100644 index 0000000..34abcae --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/menu/GetMenusUseCase.kt @@ -0,0 +1,56 @@ +package com.example.domain.usecase.menu + +import com.example.domain.model.Category +import com.example.domain.model.Menu +import com.example.domain.repository.MenuRepository +import javax.inject.Inject + +/** + * 메뉴 조회 UseCase + * - 카테고리별로 메뉴를 조회하는 비즈니스 로직 + * - UI에서 필요한 형태로 데이터를 가공 + */ + +// 일반 생성자 주입. DI 프레임워크 (예: Hilt, Dagger 등) 없이 수동으로 객체를 생성해서 주입해야 합니다. +//class GetMenusUseCase( +// private val menuRepository: MenuRepository +//) { + +// @Inject 어노테이션으로 의존성 주입이 자동화됨. +// Hilt나 Dagger 등 DI 프레임워크에서 이 클래스를 주입 대상으로 인식하게 됩니다. + +class GetMenusUseCase @Inject constructor( + private val menuRepository: MenuRepository +) { + + /** + * 모든 카테고리와 각 카테고리별 메뉴들을 조회 + * @return Pair<카테고리 리스트, 카테고리별 메뉴 맵> + */ + suspend fun execute(): Result, Map>>> { + return try { + // 1. 모든 카테고리 조회 + val categoriesResult: Result> = menuRepository.getCategories() + if (categoriesResult.isFailure) { + return Result.failure(categoriesResult.exceptionOrNull()!!) + } + + // 2. 모든 메뉴 조회 + val menusResult: Result> = menuRepository.getAllMenus() + if (menusResult.isFailure) { + return Result.failure(menusResult.exceptionOrNull()!!) + } + + val categories: List = categoriesResult.getOrThrow().sortedBy { it.order } + val allMenus: List = menusResult.getOrThrow() + + // 3. 카테고리별로 메뉴 그룹화 + val menusByCategory: Map> = allMenus.groupBy { it.categoryId } + + Result.success(Pair(categories, menusByCategory)) + + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/domain/src/main/java/com/example/domain/usecase/menu/UpdateMenuUseCase.kt b/domain/src/main/java/com/example/domain/usecase/menu/UpdateMenuUseCase.kt new file mode 100644 index 0000000..474fae7 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/menu/UpdateMenuUseCase.kt @@ -0,0 +1,42 @@ +package com.example.domain.usecase.menu + +import com.example.domain.model.Menu +import com.example.domain.repository.MenuRepository +import javax.inject.Inject + +/** + * 메뉴 수정 UseCase + * - 메뉴 수정 시 필요한 비즈니스 로직 처리 + * - 입력 데이터 검증 + */ +class UpdateMenuUseCase @Inject constructor( + private val menuRepository: MenuRepository +) { + + /** + * 메뉴를 수정 + * @param menu 수정할 메뉴 객체 + */ + suspend fun execute(menu: Menu): Result { + + // 1. 입력 데이터 검증 + if (menu.name.isBlank()) { + return Result.failure(IllegalArgumentException("메뉴 이름은 필수입니다")) + } + + if (menu.price < 0) { + return Result.failure(IllegalArgumentException("가격은 0 이상이어야 합니다")) + } + + if (menu.categoryId <= 0) { + return Result.failure(IllegalArgumentException("올바른 카테고리를 선택해주세요")) + } + + if (menu.id <= 0) { + return Result.failure(IllegalArgumentException("올바른 메뉴 ID가 아닙니다")) + } + + // 2. 메뉴 수정 + return menuRepository.updateMenu(menu) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/order/DeleteOrderUseCase.kt b/domain/src/main/java/com/example/domain/usecase/order/DeleteOrderUseCase.kt new file mode 100644 index 0000000..0d929fc --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/order/DeleteOrderUseCase.kt @@ -0,0 +1,12 @@ +package com.example.domain.usecase.order + +import com.example.domain.repository.OrderRepository + +// domain/usecase/DeleteOrderUseCase.kt +class DeleteOrderUseCase( + private val repository: OrderRepository +) { + suspend operator fun invoke(orderId: Int): Result { + return repository.deleteOrder(orderId) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/order/GetAllOrdersUseCase.kt b/domain/src/main/java/com/example/domain/usecase/order/GetAllOrdersUseCase.kt new file mode 100644 index 0000000..0d452e6 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/order/GetAllOrdersUseCase.kt @@ -0,0 +1,12 @@ +package com.example.domain.usecase.order + +import com.example.domain.model.Order +import com.example.domain.repository.OrderRepository + +class GetAllOrdersUseCase( + private val repository: OrderRepository +) { + suspend operator fun invoke(storeId: Int): Result> { + return repository.getAllOrders(storeId) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/order/GetOrderUseCase.kt b/domain/src/main/java/com/example/domain/usecase/order/GetOrderUseCase.kt new file mode 100644 index 0000000..ebacbba --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/order/GetOrderUseCase.kt @@ -0,0 +1,12 @@ +package com.example.domain.usecase.order + +import com.example.domain.model.Order +import com.example.domain.repository.OrderRepository + +class GetOrderUseCase( + private val repository: OrderRepository +) { + suspend operator fun invoke(orderId: Int): Result { + return repository.getOrder(orderId) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/sale/GetMonthlySalesUseCase.kt b/domain/src/main/java/com/example/domain/usecase/sale/GetMonthlySalesUseCase.kt new file mode 100644 index 0000000..494821e --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/sale/GetMonthlySalesUseCase.kt @@ -0,0 +1,16 @@ +package com.example.domain.usecase.sale + +// domain/usecase/GetMonthlySalesUseCase.kt + + +import com.example.domain.model.SalesData +import com.example.domain.repository.SalesRepository +import javax.inject.Inject + +class GetMonthlySalesUseCase @Inject constructor( + private val repository: SalesRepository +) { + suspend operator fun invoke(): Result> { + return repository.getMonthlySales() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/sale/GetTotalSalesUseCase.kt b/domain/src/main/java/com/example/domain/usecase/sale/GetTotalSalesUseCase.kt new file mode 100644 index 0000000..3aef33c --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/sale/GetTotalSalesUseCase.kt @@ -0,0 +1,15 @@ +package com.example.domain.usecase.sale + +// domain/usecase/GetTotalSalesUseCase.kt + +import com.example.domain.model.TotalSales +import com.example.domain.repository.SalesRepository +import javax.inject.Inject + +class GetTotalSalesUseCase @Inject constructor( + private val repository: SalesRepository +) { + suspend operator fun invoke(): Result { + return repository.getTotalSales() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/sale/GetYearlySalesUseCase.kt b/domain/src/main/java/com/example/domain/usecase/sale/GetYearlySalesUseCase.kt new file mode 100644 index 0000000..ed31198 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/sale/GetYearlySalesUseCase.kt @@ -0,0 +1,15 @@ +package com.example.domain.usecase.sale + +// domain/usecase/GetYearlySalesUseCase.kt + +import com.example.domain.model.SalesData +import com.example.domain.repository.SalesRepository +import javax.inject.Inject + +class GetYearlySalesUseCase @Inject constructor( + private val repository: SalesRepository +) { + suspend operator fun invoke(): Result> { + return repository.getYearlySales() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/AddStaffUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/AddStaffUseCase.kt new file mode 100644 index 0000000..e9b4397 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/AddStaffUseCase.kt @@ -0,0 +1,35 @@ +package com.example.domain.usecase.staff + +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class AddStaffUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(staff: Staff): Result { + // 비즈니스 로직 검증 + if (staff.name.isBlank()) { + return Result.failure(IllegalArgumentException("이름은 필수 입력입니다.")) + } + + if (staff.phoneNumber.isBlank() || !isValidPhoneNumber(staff.phoneNumber)) { + return Result.failure(IllegalArgumentException("올바른 전화번호를 입력해주세요.")) + } + + if (staff.hourlyWage < 9620) { // 2023년 최저임금 기준 + return Result.failure(IllegalArgumentException("시급은 최저임금 이상이어야 합니다.")) + } + + if (staff.accountNumber.isBlank()) { + return Result.failure(IllegalArgumentException("계좌번호는 필수 입력입니다.")) + } + + return staffRepository.addStaff(staff) + } + + private fun isValidPhoneNumber(phoneNumber: String): Boolean { + val phonePattern = "^010\\d{8}$".toRegex() + return phonePattern.matches(phoneNumber.replace("-", "")) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/DeleteStaffUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/DeleteStaffUseCase.kt new file mode 100644 index 0000000..a1d0aa9 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/DeleteStaffUseCase.kt @@ -0,0 +1,17 @@ +// :domain/src/main/java/com/example/domain/usecase/staff/DeleteStaffUseCase.kt +package com.example.domain.usecase.staff + +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class DeleteStaffUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(id: Long): Result { + if (id <= 0) { + return Result.failure(IllegalArgumentException("유효하지 않은 직원 ID입니다.")) + } + + return staffRepository.deleteStaff(id) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/GetStaffByIdUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/GetStaffByIdUseCase.kt new file mode 100644 index 0000000..9633f5a --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/GetStaffByIdUseCase.kt @@ -0,0 +1,14 @@ +// :domain/src/main/java/com/example/domain/usecase/staff/GetStaffByIdUseCase.kt +package com.example.domain.usecase.staff + +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class GetStaffByIdUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(id: Long): Result { + return staffRepository.getStaffById(id) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/GetStaffListUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/GetStaffListUseCase.kt new file mode 100644 index 0000000..9ad22c0 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/GetStaffListUseCase.kt @@ -0,0 +1,14 @@ +// :domain/src/main/java/com/example/domain/usecase/staff/GetStaffListUseCase.kt +package com.example.domain.usecase.staff + +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class GetStaffListUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(): Result> { + return staffRepository.getStaffList() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/SearchStaffUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/SearchStaffUseCase.kt new file mode 100644 index 0000000..cb9de28 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/SearchStaffUseCase.kt @@ -0,0 +1,25 @@ +// :domain/src/main/java/com/example/domain/usecase/staff/SearchStaffUseCase.kt +package com.example.domain.usecase.staff + +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class SearchStaffUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(query: String): Result> { + val trimmedQuery = query.trim() + + if (trimmedQuery.isBlank()) { + // 빈 검색어면 전체 목록 반환 + return staffRepository.getStaffList() + } + + if (trimmedQuery.length < 2) { + return Result.failure(IllegalArgumentException("검색어는 2글자 이상 입력해주세요.")) + } + + return staffRepository.searchStaff(trimmedQuery) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/domain/usecase/staff/UpdateStaffUseCase.kt b/domain/src/main/java/com/example/domain/usecase/staff/UpdateStaffUseCase.kt new file mode 100644 index 0000000..fd6e664 --- /dev/null +++ b/domain/src/main/java/com/example/domain/usecase/staff/UpdateStaffUseCase.kt @@ -0,0 +1,36 @@ +// :domain/src/main/java/com/example/domain/usecase/staff/UpdateStaffUseCase.kt +package com.example.domain.usecase.staff + +import com.example.domain.model.Staff +import com.example.domain.repository.StaffRepository +import javax.inject.Inject + +class UpdateStaffUseCase @Inject constructor( + private val staffRepository: StaffRepository +) { + suspend operator fun invoke(staff: Staff): Result { + // 동일한 검증 로직 적용 + if (staff.name.isBlank()) { + return Result.failure(IllegalArgumentException("이름은 필수 입력입니다.")) + } + + if (staff.phoneNumber.isBlank() || !isValidPhoneNumber(staff.phoneNumber)) { + return Result.failure(IllegalArgumentException("올바른 전화번호를 입력해주세요.")) + } + + if (staff.hourlyWage < 9620) { + return Result.failure(IllegalArgumentException("시급은 최저임금 이상이어야 합니다.")) + } + + if (staff.accountNumber.isBlank()) { + return Result.failure(IllegalArgumentException("계좌번호는 필수 입력입니다.")) + } + + return staffRepository.updateStaff(staff) + } + + private fun isValidPhoneNumber(phoneNumber: String): Boolean { + val phonePattern = "^010\\d{8}$".toRegex() + return phonePattern.matches(phoneNumber.replace("-", "")) + } +} diff --git a/feature/menu/build.gradle.kts b/feature/menu/build.gradle.kts index 5c0dad0..cc15cbd 100644 --- a/feature/menu/build.gradle.kts +++ b/feature/menu/build.gradle.kts @@ -57,11 +57,16 @@ android { dependencies { // 코어 UI 모듈 의존성 implementation(project(":core:ui")) + implementation(project(":domain")) + implementation(project(":core:common")) // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + // 이미지 처리 및 권한 관련 + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.core:core-ktx:1.12.0") // Compose 기본 의존성 implementation("androidx.compose.ui:ui") @@ -80,4 +85,8 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + + // coil + implementation("io.coil-kt:coil-compose:2.5.0") + } diff --git a/feature/menu/src/main/java/com/example/menu/component/AddCategoryDialog.kt b/feature/menu/src/main/java/com/example/menu/component/AddCategoryDialog.kt new file mode 100644 index 0000000..b04f6ef --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/AddCategoryDialog.kt @@ -0,0 +1,117 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** + * 카테고리 추가 다이얼로그 + * - 카테고리 이름 입력 + * - 입력 검증 + * - 추가/취소 버튼 + */ +@Composable +fun AddCategoryDialog( + isVisible: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + modifier: Modifier = Modifier +) { + var categoryName by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + + // 다이얼로그가 열릴 때마다 상태 초기화 + LaunchedEffect(isVisible) { + if (isVisible) { + categoryName = "" + isError = false + errorMessage = "" + } + } + + if (isVisible) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 다이얼로그 제목 + Text( + text = "카테고리 추가", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + // 입력 필드 + OutlinedTextField( + value = categoryName, + onValueChange = { newValue -> + categoryName = newValue + // 입력할 때마다 에러 상태 초기화 + if (isError) { + isError = false + errorMessage = "" + } + }, + label = { Text("카테고리 이름") }, + placeholder = { Text("예: 디저트, 음료, 사이드 등") }, + isError = isError, + supportingText = if (isError) { + { Text(errorMessage, color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // 버튼 영역 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onDismiss + ) { + Text("취소") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + // 입력 검증 + when { + categoryName.isBlank() -> { + isError = true + errorMessage = "카테고리 이름을 입력해주세요" + } + categoryName.length > 20 -> { + isError = true + errorMessage = "카테고리 이름은 20자 이하로 입력해주세요" + } + else -> { + onConfirm(categoryName.trim()) + } + } + } + ) { + Text("추가") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/CategoryDropdown.kt b/feature/menu/src/main/java/com/example/menu/component/CategoryDropdown.kt new file mode 100644 index 0000000..2a7abd2 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/CategoryDropdown.kt @@ -0,0 +1,93 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.domain.model.Category + +/** + * 카테고리 선택 드롭다운 컴포넌트 + * - 카테고리 목록에서 선택 + * - 에러 상태 표시 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryDropdown( + categories: List, + selectedCategoryId: Long, + onCategorySelected: (Long) -> Unit, + error: String = "", + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val selectedCategory = categories.find { it.id == selectedCategoryId } + + Column(modifier = modifier) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedCategory?.name ?: "카테고리 선택", + onValueChange = { }, + readOnly = true, + label = { Text("카테고리") }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "드롭다운" + ) + }, + isError = error.isNotEmpty(), + supportingText = if (error.isNotEmpty()) { + { Text(error, color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = if (selectedCategory != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } + ) + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + categories.forEach { category -> + DropdownMenuItem( + text = { + Text( + text = category.name, + color = if (category.id == selectedCategoryId) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + }, + onClick = { + onCategorySelected(category.id) + expanded = false + }, + colors = MenuDefaults.itemColors( + textColor = if (category.id == selectedCategoryId) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/CategoryItem.kt b/feature/menu/src/main/java/com/example/menu/component/CategoryItem.kt new file mode 100644 index 0000000..4e07fdf --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/CategoryItem.kt @@ -0,0 +1,112 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.domain.model.Category +import com.example.ui.theme.Spacing +import com.example.ui.theme.CornerRadius +import com.example.ui.theme.barrionColors + +/** + * 카테고리 아이템 - 색상 및 레이아웃 개선 + */ +@Composable +fun CategoryItem( + category: Category, + menuCount: Int, + order: Int, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = Spacing.XSmall), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.grayVeryLight + ), + shape = RoundedCornerShape(CornerRadius.Medium) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.Medium), + verticalAlignment = Alignment.CenterVertically + ) { + // 순서 번호 + Surface( + modifier = Modifier.size(32.dp), + shape = RoundedCornerShape(CornerRadius.Small), + color = MaterialTheme.barrionColors.primaryBlue + ) { + Box( + contentAlignment = Alignment.Center + ) { + Text( + text = order.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.white + ) + } + } + + Spacer(modifier = Modifier.width(Spacing.Medium)) + + // 카테고리 정보 + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.grayBlack + ) + + Text( + text = "(${menuCount}개 메뉴)", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } + + // 기본 카테고리 표시 또는 삭제 버튼 + if (category.isDefault) { + Surface( + shape = RoundedCornerShape(CornerRadius.Small), + color = MaterialTheme.barrionColors.blueVeryPale + ) { + Text( + text = "기본 카테고리", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.barrionColors.primaryBlue, + modifier = Modifier.padding( + horizontal = Spacing.Small, + vertical = Spacing.XSmall + ) + ) + } + } else { + IconButton( + onClick = onDelete, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.barrionColors.error + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "카테고리 삭제" + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/CategoryManagementCard.kt b/feature/menu/src/main/java/com/example/menu/component/CategoryManagementCard.kt new file mode 100644 index 0000000..d23fa55 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/CategoryManagementCard.kt @@ -0,0 +1,67 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * 카테고리 관리 카드 컴포넌트 + * - 메인 화면 상단에 표시되는 카테고리 관리 진입 카드 + * - 클릭 시 카테고리 관리 화면으로 이동 + */ +@Composable +fun CategoryManagementCard( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = { + println("CategoryManagementCard 클릭됨!") // 디버그 로그 추가 + onClick() + }, + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.GridView, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "카테고리 관리", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "카테고리 추가, 삭제 및 순서 변경", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "이동", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/CategorySection.kt b/feature/menu/src/main/java/com/example/menu/component/CategorySection.kt new file mode 100644 index 0000000..3ca384c --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/CategorySection.kt @@ -0,0 +1,98 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.domain.model.Category +import com.example.domain.model.Menu + +/** + * 카테고리별 메뉴 섹션 컴포넌트 + * - 카테고리 제목과 해당 카테고리의 메뉴들을 가로 스크롤로 표시 + * - 더보기 버튼과 추가 버튼 포함 + */ +@Composable +fun CategorySection( + category: Category, + menus: List, + onSeeMore: () -> Unit, + onAddMenu: () -> Unit, + onDeleteMenu: (Menu) -> Unit, + onEditMenu: (Menu) -> Unit, // 새로운 파라미터 추가 + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + // 카테고리 헤더 (제목 + 더보기 + 추가 버튼) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${category.name} (${menus.size}개 메뉴)", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Row { + IconButton(onClick = onAddMenu) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "메뉴 추가" + ) + } + + TextButton(onClick = onSeeMore) { + Text("더보기") + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 메뉴 목록 (가로 스크롤) + if (menus.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 4.dp) + ) { + // 메뉴 목록 (가로 스크롤) 부분에서 + items(menus) { menu -> + MenuCard( + menu = menu, + onEdit = { onEditMenu(menu) }, // 수정: 메뉴 편집 콜백 + onDelete = { onDeleteMenu(menu) } + ) + } + } + } else { + // 메뉴가 없을 때 표시 + Card( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "등록된 메뉴가 없습니다", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/DeleteCategoryDialog.kt b/feature/menu/src/main/java/com/example/menu/component/DeleteCategoryDialog.kt new file mode 100644 index 0000000..1952bb7 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/DeleteCategoryDialog.kt @@ -0,0 +1,47 @@ +package com.example.menu.component + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight + +/** + * 카테고리 삭제 확인 다이얼로그 + * - 간단한 확인/취소 다이얼로그 + */ +@Composable +fun DeleteCategoryDialog( + isVisible: Boolean, + categoryName: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (isVisible) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "카테고리 삭제", + fontWeight = FontWeight.Bold + ) + }, + text = { + Text("'$categoryName' 카테고리를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") + }, + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("삭제") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("취소") + } + } + ) + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/DeleteMenuDialog.kt b/feature/menu/src/main/java/com/example/menu/component/DeleteMenuDialog.kt new file mode 100644 index 0000000..5ad1231 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/DeleteMenuDialog.kt @@ -0,0 +1,47 @@ +package com.example.menu.component + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight + +/** + * 메뉴 삭제 확인 다이얼로그 + * - 메뉴 삭제 시 확인 다이얼로그 + */ +@Composable +fun DeleteMenuDialog( + isVisible: Boolean, + menuName: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (isVisible) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "메뉴 삭제", + fontWeight = FontWeight.Bold + ) + }, + text = { + Text("'$menuName' 메뉴를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") + }, + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("삭제") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("취소") + } + } + ) + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/ImagePickerDialog.kt b/feature/menu/src/main/java/com/example/menu/component/ImagePickerDialog.kt new file mode 100644 index 0000000..a881767 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/ImagePickerDialog.kt @@ -0,0 +1,142 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** + * 이미지 선택 다이얼로그 + * - 카메라로 촬영 + * - 갤러리에서 선택 + */ +@Composable +fun ImagePickerDialog( + isVisible: Boolean, + onDismiss: () -> Unit, + onCameraSelected: () -> Unit, + onGallerySelected: () -> Unit, + modifier: Modifier = Modifier +) { + if (isVisible) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 다이얼로그 제목 + Text( + text = "이미지 선택", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + // 카메라 버튼 + Card( + onClick = { + onCameraSelected() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = "카메라", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = "카메라로 촬영", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "새로운 사진을 촬영합니다", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // 갤러리 버튼 + Card( + onClick = { + onGallerySelected() + onDismiss() + }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PhotoLibrary, + contentDescription = "갤러리", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = "갤러리에서 선택", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = "기존 사진을 선택합니다", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // 취소 버튼 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("취소") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/ImageUploadArea.kt b/feature/menu/src/main/java/com/example/menu/component/ImageUploadArea.kt new file mode 100644 index 0000000..5b72a29 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/ImageUploadArea.kt @@ -0,0 +1,295 @@ +package com.example.menu.component + +import android.Manifest +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import coil.compose.AsyncImage +import coil.request.ImageRequest +import java.io.ByteArrayOutputStream +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +/** + * 이미지 업로드 영역 컴포넌트 (실제 이미지 선택 기능 포함) + * - 갤러리에서 이미지 선택 + * - 카메라로 사진 촬영 + * - Base64로 변환하여 전달 + */ +@Composable +fun ImageUploadArea( + imageUrl: String, + onImageSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + // 권한 및 다이얼로그 상태 + var showImagePicker by remember { mutableStateOf(false) } + var showPermissionDialog by remember { mutableStateOf(false) } + + // 카메라 촬영용 임시 파일 + val photoFile = remember { + File.createTempFile( + "photo_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", + ".jpg", + context.cacheDir + ) + } + + val photoUri = remember { + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile + ) + } + + // 갤러리 선택 런처 + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { imageUri -> + try { + val inputStream = context.contentResolver.openInputStream(imageUri) + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + // Bitmap을 Base64로 변환 + val base64String = bitmapToBase64(bitmap) + onImageSelected("data:image/jpeg;base64,$base64String") + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + // 카메라 촬영 런처 + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success: Boolean -> + if (success) { + try { + val bitmap = android.graphics.BitmapFactory.decodeFile(photoFile.absolutePath) + val base64String = bitmapToBase64(bitmap) + onImageSelected("data:image/jpeg;base64,$base64String") + + // 임시 파일 삭제 + photoFile.delete() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + // 권한 요청 런처 + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val cameraGranted = permissions[Manifest.permission.CAMERA] ?: false + val storageGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions[Manifest.permission.READ_MEDIA_IMAGES] ?: false + } else { + permissions[Manifest.permission.READ_EXTERNAL_STORAGE] ?: false + } + + if (cameraGranted || storageGranted) { + showImagePicker = true + } else { + showPermissionDialog = true + } + } + + Box( + modifier = modifier + .height(200.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + width = 2.dp, + color = if (imageUrl.isEmpty()) { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + }, + shape = RoundedCornerShape(12.dp) + ) + .clickable { + // 권한 체크 + val cameraPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) + } else { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) + } + + if (cameraPermission == PackageManager.PERMISSION_GRANTED || + storagePermission == PackageManager.PERMISSION_GRANTED) { + showImagePicker = true + } else { + // 권한 요청 + val permissions = mutableListOf() + if (cameraPermission != PackageManager.PERMISSION_GRANTED) { + permissions.add(Manifest.permission.CAMERA) + } + if (storagePermission != PackageManager.PERMISSION_GRANTED) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.READ_MEDIA_IMAGES) + } else { + permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + permissionLauncher.launch(permissions.toTypedArray()) + } + }, + contentAlignment = Alignment.Center + ) { +// AsyncImage 부분을 다음과 같이 수정: + + if (imageUrl.isEmpty()) { + // 이미지가 없을 때 (기존 코드 그대로) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = "이미지 추가", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "이미지를 추가하려면 탭하세요", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + } else { + // 이미지가 있을 때 - Base64 처리 개선 + if (imageUrl.startsWith("data:image")) { + // Base64 이미지인 경우 + val base64Data = imageUrl.substringAfter("base64,") + val imageBytes = Base64.decode(base64Data, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "선택된 이미지", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + } else { + // Bitmap 생성 실패 시 placeholder + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("이미지 로드 실패", color = MaterialTheme.colorScheme.error) + } + } + } else { + // 일반 URL인 경우 (기존 코드) + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = "선택된 이미지", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + } + + // 오버레이 (기존 코드 그대로) + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(8.dp) + ) { + Text( + text = "탭하여 이미지 변경", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + + // 이미지 선택 다이얼로그 + ImagePickerDialog( + isVisible = showImagePicker, + onDismiss = { showImagePicker = false }, + onCameraSelected = { + cameraLauncher.launch(photoUri) + }, + onGallerySelected = { + galleryLauncher.launch("image/*") + } + ) + + // 권한 거부 시 안내 다이얼로그 + if (showPermissionDialog) { + AlertDialog( + onDismissRequest = { showPermissionDialog = false }, + title = { Text("권한이 필요합니다") }, + text = { Text("이미지를 선택하려면 카메라 또는 저장소 권한이 필요합니다.") }, + confirmButton = { + TextButton(onClick = { showPermissionDialog = false }) { + Text("확인") + } + } + ) + } +} + +/** + * Bitmap을 Base64 문자열로 변환 + */ +private fun bitmapToBase64(bitmap: Bitmap): String { + val byteArrayOutputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, byteArrayOutputStream) + val byteArray = byteArrayOutputStream.toByteArray() + return Base64.encodeToString(byteArray, Base64.NO_WRAP) +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/component/MenuCard.kt b/feature/menu/src/main/java/com/example/menu/component/MenuCard.kt new file mode 100644 index 0000000..cdc6b7a --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/component/MenuCard.kt @@ -0,0 +1,116 @@ +package com.example.menu.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.domain.model.Menu +import java.text.NumberFormat +import java.util.Locale + +/** + * 메뉴 카드 컴포넌트 + * - 개별 메뉴 정보를 표시하는 카드 + * - 이미지, 이름, 가격, 수정/삭제 버튼 포함 + */ +@Composable +fun MenuCard( + menu: Menu, + onEdit: (Menu) -> Unit, + onDelete: (Menu) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.width(160.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + // 메뉴 이미지 + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(menu.imageUrl.ifEmpty { "https://via.placeholder.com/150" }) + .crossfade(true) + .build(), + contentDescription = menu.name, + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 메뉴 이름 + Text( + text = menu.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // 메뉴 가격 + Text( + text = formatPrice(menu.price), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 수정/삭제 버튼 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton( + onClick = { onEdit(menu) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "수정", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + + IconButton( + onClick = { onDelete(menu) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "삭제", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + +/** + * 가격을 한국 원화 형식으로 포맷팅 + */ +private fun formatPrice(price: Int): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(price) + "원" +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/screen/AddMenuScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/AddMenuScreen.kt new file mode 100644 index 0000000..eeed833 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/screen/AddMenuScreen.kt @@ -0,0 +1,265 @@ +package com.example.menu.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.menu.component.ImageUploadArea +import com.example.menu.component.CategoryDropdown +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuEffect +import com.example.menu.viewmodel.MenuViewModel + +/** + * 메뉴 추가 화면 + * - 메뉴 정보 입력 폼 + * - 이미지 업로드 + * - 카테고리 선택 + * - 입력 검증 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddMenuScreen( + selectedCategoryId: Long? = null, // 미리 선택된 카테고리 (카테고리 상세에서 온 경우) + viewModel: MenuViewModel, + onNavigateBack: () -> Unit = {} +) { + // State 구독 + val state by viewModel.state.collectAsStateWithLifecycle() + + // 폼 상태들 + var menuName by remember { mutableStateOf("") } + var price by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(selectedCategoryId ?: 0L) } + var imageUrl by remember { mutableStateOf("") } + var selectedBase64Image by remember { mutableStateOf(null) } // 추가 + + // 에러 상태들 + var nameError by remember { mutableStateOf("") } + var priceError by remember { mutableStateOf("") } + var categoryError by remember { mutableStateOf("") } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is MenuEffect.MenuAddedSuccessfully -> { + onNavigateBack() // 성공 시 뒤로가기 + } + is MenuEffect.ShowError -> { + // TODO: 토스트 메시지 또는 스낵바 표시 + } + else -> {} + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("메뉴 추가") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + actions = { + TextButton( + onClick = { + // 입력 검증 + var hasError = false + + if (menuName.isBlank()) { + nameError = "메뉴 이름을 입력해주세요" + hasError = true + } else { + nameError = "" + } + + val priceValue = price.toIntOrNull() + if (priceValue == null || priceValue < 0) { + priceError = "올바른 가격을 입력해주세요" + hasError = true + } else { + priceError = "" + } + + if (selectedCategory == 0L) { + categoryError = "카테고리를 선택해주세요" + hasError = true + } else { + categoryError = "" + } + + // 검증 통과 시 메뉴 추가 + if (!hasError) { + println("🖼️ UI - 선택된 base64 이미지: ${selectedBase64Image?.take(50) ?: "없음"}...") + + viewModel.handleIntent( + MenuIntent.AddMenu( + name = menuName.trim(), + price = priceValue!!, + categoryId = selectedCategory, + description = description.trim(), + imageUrl = imageUrl, + base64Image = selectedBase64Image // 추가 + ) + ) + } + } + ) { + Text( + text = "추가", + fontWeight = FontWeight.Bold + ) + } + } + ) + } + ) { paddingValues -> + AddMenuContent( + menuName = menuName, + onMenuNameChange = { + menuName = it + nameError = "" + }, + nameError = nameError, + price = price, + onPriceChange = { + price = it + priceError = "" + }, + priceError = priceError, + description = description, + onDescriptionChange = { description = it }, + selectedCategory = selectedCategory, + onCategoryChange = { + selectedCategory = it + categoryError = "" + }, + categoryError = categoryError, + categories = state.categories, + imageUrl = imageUrl, + onImageChange = { imageUrl = it }, + selectedBase64Image = selectedBase64Image, // 추가 + onBase64ImageChange = { base64 -> // 추가 + println("🖼️ ImageUpload - 이미지 선택됨: ${base64?.take(50) ?: "null"}...") + selectedBase64Image = base64 + }, + modifier = Modifier.padding(paddingValues) + ) + } +} + +/** + * 메뉴 추가 화면 내용 + */ +@Composable +private fun AddMenuContent( + menuName: String, + onMenuNameChange: (String) -> Unit, + nameError: String, + price: String, + onPriceChange: (String) -> Unit, + priceError: String, + description: String, + onDescriptionChange: (String) -> Unit, + selectedCategory: Long, + onCategoryChange: (Long) -> Unit, + categoryError: String, + categories: List, + imageUrl: String, + onImageChange: (String) -> Unit, + selectedBase64Image: String?, // 추가 + onBase64ImageChange: (String?) -> Unit, // 추가 + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 이미지 업로드 영역 + ImageUploadArea( + imageUrl = imageUrl, + onImageSelected = { base64OrUrl -> + println("🖼️ ImageUploadArea - 이미지 받음: ${base64OrUrl.take(50)}...") + + if (base64OrUrl.startsWith("data:image")) { + // Base64 이미지인 경우 + onBase64ImageChange(base64OrUrl) // base64 데이터 저장 + println("🖼️ Base64 데이터 저장됨") + } else { + // 일반 URL인 경우 + onImageChange(base64OrUrl) // URL 저장 + println("🖼️ URL 저장됨: $base64OrUrl") + } + }, + modifier = Modifier.fillMaxWidth() + ) + + // 메뉴 이름 + OutlinedTextField( + value = menuName, + onValueChange = onMenuNameChange, + label = { Text("메뉴 이름") }, + placeholder = { Text("메뉴 이름 입력") }, + isError = nameError.isNotEmpty(), + supportingText = if (nameError.isNotEmpty()) { + { Text(nameError, color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // 가격 + OutlinedTextField( + value = price, + onValueChange = onPriceChange, + label = { Text("가격") }, + placeholder = { Text("0") }, + isError = priceError.isNotEmpty(), + supportingText = if (priceError.isNotEmpty()) { + { Text(priceError, color = MaterialTheme.colorScheme.error) } + } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // 카테고리 선택 + CategoryDropdown( + categories = categories, + selectedCategoryId = selectedCategory, + onCategorySelected = onCategoryChange, + error = categoryError, + modifier = Modifier.fillMaxWidth() + ) + + // 설명 + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, // 수정: onDescriptionChange → onValueChange + label = { Text("설명") }, + placeholder = { Text("메뉴 설명 입력") }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + maxLines = 4 + ) + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/screen/CategoryDetailScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/CategoryDetailScreen.kt new file mode 100644 index 0000000..43b0265 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/screen/CategoryDetailScreen.kt @@ -0,0 +1,332 @@ +package com.example.menu.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.menu.component.DeleteMenuDialog +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuEffect +import com.example.menu.viewmodel.MenuViewModel +import com.example.ui.theme.Spacing +import com.example.ui.theme.CornerRadius +import com.example.ui.theme.barrionColors +import java.text.NumberFormat +import java.util.Locale + +/** + * 카테고리 상세 화면 - 디자인 개선 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryDetailScreen( + categoryId: Long, + categoryName: String, + viewModel: MenuViewModel, + onNavigateBack: () -> Unit = {}, + onNavigateToAddMenu: (Long) -> Unit = {}, + onNavigateToEditMenu: (Long) -> Unit = {} +) { + // State 구독 + val state by viewModel.state.collectAsStateWithLifecycle() + + // 해당 카테고리의 메뉴들 + val categoryMenus = state.getMenusForCategory(categoryId) + + // 메뉴 삭제 다이얼로그 상태 + var showDeleteMenuDialog by remember { mutableStateOf(false) } + var menuToDelete by remember { mutableStateOf(null) } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is MenuEffect.MenuDeletedSuccessfully -> { + showDeleteMenuDialog = false + menuToDelete = null + } + else -> {} + } + } + } + + Scaffold( + containerColor = MaterialTheme.barrionColors.white, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "$categoryName 메뉴", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.barrionColors.grayBlack + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + tint = MaterialTheme.barrionColors.grayBlack + ) + } + }, + actions = { + IconButton( + onClick = { onNavigateToAddMenu(categoryId) } + ) { + Icon( + Icons.Default.Add, + contentDescription = "메뉴 추가", + tint = MaterialTheme.barrionColors.primaryBlue + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack, + navigationIconContentColor = MaterialTheme.barrionColors.grayBlack, + actionIconContentColor = MaterialTheme.barrionColors.primaryBlue + ), + windowInsets = WindowInsets.statusBars // 헤더 위로 올리기 + ) + } + ) { paddingValues -> + CategoryDetailContent( + categoryName = categoryName, + menus = categoryMenus, + onDeleteMenu = { menu -> + menuToDelete = menu + showDeleteMenuDialog = true + }, + onEditMenu = onNavigateToEditMenu, + onAddMenu = { onNavigateToAddMenu(categoryId) }, + modifier = Modifier.padding(paddingValues) + ) + + // 메뉴 삭제 확인 다이얼로그 + DeleteMenuDialog( + isVisible = showDeleteMenuDialog, + menuName = menuToDelete?.name ?: "", + onDismiss = { + showDeleteMenuDialog = false + menuToDelete = null + }, + onConfirm = { + menuToDelete?.let { menu -> + viewModel.handleIntent(MenuIntent.DeleteMenu(menu.id)) + } + } + ) + } +} + +/** + * 카테고리 상세 화면 내용 - 세로 카드 디자인 + */ +@Composable +private fun CategoryDetailContent( + categoryName: String, + menus: List, + onDeleteMenu: (com.example.domain.model.Menu) -> Unit, + onEditMenu: (Long) -> Unit, + onAddMenu: () -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(Spacing.Medium), + verticalArrangement = Arrangement.spacedBy(Spacing.Medium) + ) { + if (menus.isNotEmpty()) { + items(menus) { menu -> + // 세로 카드 디자인 (디자인 이미지 스타일) + MenuVerticalCard( + menu = menu, + onEdit = { onEditMenu(menu.id) }, + onDelete = { onDeleteMenu(menu) } + ) + } + } else { + // 메뉴가 없을 때 + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Spacing.XXLarge), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.grayVeryLight + ), + shape = RoundedCornerShape(CornerRadius.Medium) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.XXLarge), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$categoryName 카테고리에\n등록된 메뉴가 없습니다", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.barrionColors.grayMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + Spacer(modifier = Modifier.height(Spacing.Medium)) + + Button( + onClick = onAddMenu, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.primaryBlue + ) + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(Spacing.Small)) + Text( + text = "첫 메뉴 추가하기", + color = MaterialTheme.barrionColors.white + ) + } + } + } + } + } + } +} + +/** + * 세로형 메뉴 카드 (디자인 이미지 스타일) + */ +@Composable +private fun MenuVerticalCard( + menu: com.example.domain.model.Menu, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = Spacing.XSmall), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.grayVeryLight + ), + shape = RoundedCornerShape(CornerRadius.Medium) + ) { + Column( + modifier = Modifier.padding(Spacing.Medium) + ) { + // 메뉴 이미지 + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(menu.imageUrl.ifEmpty { "https://via.placeholder.com/300x200" }) + .crossfade(true) + .build(), + contentDescription = menu.name, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(CornerRadius.Small)), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.height(Spacing.Small)) + + // 메뉴 정보 + Text( + text = menu.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.grayBlack + ) + + Text( + text = NumberFormat.getNumberInstance(Locale.KOREA).format(menu.price) + "원", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.barrionColors.primaryBlue, + fontWeight = FontWeight.SemiBold + ) + + if (menu.description.isNotEmpty()) { + Spacer(modifier = Modifier.height(Spacing.XSmall)) + Text( + text = menu.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.barrionColors.grayMedium + ) + } + + Spacer(modifier = Modifier.height(Spacing.Small)) + + // 수정/삭제 버튼 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.Small) + ) { + // 수정 버튼 + OutlinedButton( + onClick = onEdit, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.barrionColors.primaryBlue + ), + border = ButtonDefaults.outlinedButtonBorder.copy( + width = 1.dp, + brush = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.barrionColors.primaryBlue + ).brush + ) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "수정", + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(Spacing.XSmall)) + Text("수정") + } + + // 삭제 버튼 + OutlinedButton( + onClick = onDelete, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.barrionColors.error + ), + border = ButtonDefaults.outlinedButtonBorder.copy( + width = 1.dp, + brush = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.barrionColors.error + ).brush + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "삭제", + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(Spacing.XSmall)) + Text("삭제") + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/screen/CategoryManagementScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/CategoryManagementScreen.kt new file mode 100644 index 0000000..1406ed7 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/screen/CategoryManagementScreen.kt @@ -0,0 +1,262 @@ +package com.example.menu.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.menu.component.CategoryItem +import com.example.menu.component.AddCategoryDialog +import com.example.menu.component.DeleteCategoryDialog +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuEffect +import com.example.menu.viewmodel.MenuViewModel +import com.example.ui.theme.Spacing +import com.example.ui.theme.CornerRadius +import com.example.ui.theme.barrionColors + +/** + * 카테고리 관리 화면 - 색상 및 디자인 개선 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryManagementScreen( + viewModel: MenuViewModel, + onNavigateBack: () -> Unit = {} +) { + // State 구독 + val state by viewModel.state.collectAsStateWithLifecycle() + + // 다이얼로그 상태들 + var showAddCategoryDialog by remember { mutableStateOf(false) } + var showDeleteCategoryDialog by remember { mutableStateOf(false) } + var categoryToDelete by remember { mutableStateOf(null) } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is MenuEffect.CategoryAddedSuccessfully -> { + showAddCategoryDialog = false + } + is MenuEffect.CategoryDeletedSuccessfully -> { + showDeleteCategoryDialog = false + categoryToDelete = null + } + else -> {} + } + } + } + + Scaffold( + containerColor = MaterialTheme.barrionColors.white, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "카테고리 관리", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.barrionColors.grayBlack + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + tint = MaterialTheme.barrionColors.grayBlack + ) + } + }, + actions = { + IconButton( + onClick = { showAddCategoryDialog = true } + ) { + Icon( + Icons.Default.Add, + contentDescription = "카테고리 추가", + tint = MaterialTheme.barrionColors.primaryBlue + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack, + navigationIconContentColor = MaterialTheme.barrionColors.grayBlack, + actionIconContentColor = MaterialTheme.barrionColors.primaryBlue + ), + windowInsets = WindowInsets.statusBars + ) + } + ) { paddingValues -> + CategoryManagementContent( + state = state, + onIntent = viewModel::handleIntent, + onNavigateToAddCategory = { showAddCategoryDialog = true }, + onDeleteCategory = { category -> + categoryToDelete = category + showDeleteCategoryDialog = true + }, + modifier = Modifier.padding(paddingValues) + ) + + // 카테고리 추가 다이얼로그 + AddCategoryDialog( + isVisible = showAddCategoryDialog, + onDismiss = { showAddCategoryDialog = false }, + onConfirm = { categoryName -> + viewModel.handleIntent(MenuIntent.AddCategory(categoryName)) + } + ) + + // 카테고리 삭제 확인 다이얼로그 + DeleteCategoryDialog( + isVisible = showDeleteCategoryDialog, + categoryName = categoryToDelete?.name ?: "", + onDismiss = { + showDeleteCategoryDialog = false + categoryToDelete = null + }, + onConfirm = { + categoryToDelete?.let { category -> + viewModel.handleIntent(MenuIntent.DeleteCategory(category.id)) + } + } + ) + } +} + +/** + * 카테고리 관리 화면 내용 - 색상 개선 + */ +@Composable +private fun CategoryManagementContent( + state: com.example.menu.type.MenuState, + onIntent: (MenuIntent) -> Unit, + onNavigateToAddCategory: () -> Unit, + onDeleteCategory: (com.example.domain.model.Category) -> Unit, + modifier: Modifier = Modifier +) { + when { + state.isLoading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.barrionColors.primaryBlue + ) + } + } + + state.error != null -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.error + ) + Spacer(modifier = Modifier.height(Spacing.Medium)) + Button( + onClick = { onIntent(MenuIntent.RefreshData) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.primaryBlue + ) + ) { + Text( + text = "다시 시도", + color = MaterialTheme.barrionColors.white + ) + } + } + } + } + + else -> { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(Spacing.Medium), + verticalArrangement = Arrangement.spacedBy(Spacing.Small) + ) { + // 카테고리 목록 + itemsIndexed(state.categories) { index, category -> + CategoryItem( + category = category, + menuCount = state.getMenusForCategory(category.id).size, + order = index + 1, + onDelete = { + if (!category.isDefault) { + onDeleteCategory(category) + } + } + ) + } + + // 하단 정보 및 추가 버튼 + item { + Spacer(modifier = Modifier.height(Spacing.Large)) + + Text( + text = "카테고리 순서를 변경하려면 드래그하여 위치를 조정하세요.\n카테고리 추가는 상단 + 버튼을 이용하세요.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.barrionColors.grayMedium, + modifier = Modifier.padding(vertical = Spacing.Medium) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "총 카테고리", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayBlack + ) + Text( + text = "${state.categories.size}개", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.grayBlack + ) + } + + Button( + onClick = onNavigateToAddCategory, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.primaryBlue + ), + shape = RoundedCornerShape(CornerRadius.Medium) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.barrionColors.white + ) + Spacer(modifier = Modifier.width(Spacing.Small)) + Text( + text = "카테고리 추가", + color = MaterialTheme.barrionColors.white + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/screen/EditMenuScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/EditMenuScreen.kt new file mode 100644 index 0000000..d2443c4 --- /dev/null +++ b/feature/menu/src/main/java/com/example/menu/screen/EditMenuScreen.kt @@ -0,0 +1,283 @@ +package com.example.menu.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.menu.component.ImageUploadArea +import com.example.menu.component.CategoryDropdown +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuEffect +import com.example.menu.viewmodel.MenuViewModel + +/** + * 메뉴 수정 화면 + * - 기존 메뉴 정보로 폼 초기화 + * - 메뉴 정보 수정 + * - 입력 검증 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditMenuScreen( + menuId: Long, + viewModel: MenuViewModel, + onNavigateBack: () -> Unit = {} +) { + // State 구독 + val state by viewModel.state.collectAsStateWithLifecycle() + + // 수정할 메뉴 찾기 + val menuToEdit = remember(state.menusByCategory, menuId) { + state.menusByCategory.values.flatten().find { it.id == menuId } + } + + // 폼 상태들 (기존 메뉴 데이터로 초기화) + var menuName by remember(menuToEdit) { mutableStateOf(menuToEdit?.name ?: "") } + var price by remember(menuToEdit) { mutableStateOf(menuToEdit?.price?.toString() ?: "") } + var description by remember(menuToEdit) { mutableStateOf(menuToEdit?.description ?: "") } + var selectedCategory by remember(menuToEdit) { mutableStateOf(menuToEdit?.categoryId ?: 0L) } + var imageUrl by remember(menuToEdit) { mutableStateOf(menuToEdit?.imageUrl ?: "") } + + // 에러 상태들 + var nameError by remember { mutableStateOf("") } + var priceError by remember { mutableStateOf("") } + var categoryError by remember { mutableStateOf("") } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is MenuEffect.MenuUpdatedSuccessfully -> { + onNavigateBack() // 성공 시 뒤로가기 + } + is MenuEffect.ShowError -> { + // TODO: 토스트 메시지 또는 스낵바 표시 + } + else -> {} + } + } + } + + // 메뉴를 찾지 못한 경우 + if (menuToEdit == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "메뉴를 찾을 수 없습니다", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onNavigateBack) { + Text("돌아가기") + } + } + } + return + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("메뉴 수정") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + actions = { + TextButton( + onClick = { + // 입력 검증 + var hasError = false + + if (menuName.isBlank()) { + nameError = "메뉴 이름을 입력해주세요" + hasError = true + } else { + nameError = "" + } + + val priceValue = price.toIntOrNull() + if (priceValue == null || priceValue < 0) { + priceError = "올바른 가격을 입력해주세요" + hasError = true + } else { + priceError = "" + } + + if (selectedCategory == 0L) { + categoryError = "카테고리를 선택해주세요" + hasError = true + } else { + categoryError = "" + } + + // 검증 통과 시 메뉴 수정 + if (!hasError) { + val updatedMenu = menuToEdit.copy( + name = menuName.trim(), + price = priceValue!!, + categoryId = selectedCategory, + description = description.trim(), + imageUrl = imageUrl + ) + viewModel.handleIntent(MenuIntent.UpdateMenu(updatedMenu)) + } + } + ) { + Text( + text = "수정", + fontWeight = FontWeight.Bold + ) + } + } + ) + } + ) { paddingValues -> + EditMenuContent( + menuName = menuName, + onMenuNameChange = { + menuName = it + nameError = "" + }, + nameError = nameError, + price = price, + onPriceChange = { + price = it + priceError = "" + }, + priceError = priceError, + description = description, + onDescriptionChange = { description = it }, + selectedCategory = selectedCategory, + onCategoryChange = { + selectedCategory = it + categoryError = "" + }, + categoryError = categoryError, + categories = state.categories, + imageUrl = imageUrl, + onImageChange = { imageUrl = it }, + modifier = Modifier.padding(paddingValues) + ) + } +} + +/** + * 메뉴 수정 화면 내용 + */ +@Composable +private fun EditMenuContent( + menuName: String, + onMenuNameChange: (String) -> Unit, + nameError: String, + price: String, + onPriceChange: (String) -> Unit, + priceError: String, + description: String, + onDescriptionChange: (String) -> Unit, + selectedCategory: Long, + onCategoryChange: (Long) -> Unit, + categoryError: String, + categories: List, + imageUrl: String, + onImageChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 기존 메뉴 정보 표시 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "기존 메뉴 정보를 수정하세요", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + // 이미지 업로드 영역 + ImageUploadArea( + imageUrl = imageUrl, + onImageSelected = onImageChange, + modifier = Modifier.fillMaxWidth() + ) + + // 메뉴 이름 + OutlinedTextField( + value = menuName, + onValueChange = onMenuNameChange, + label = { Text("메뉴 이름") }, + placeholder = { Text("메뉴 이름 입력") }, + isError = nameError.isNotEmpty(), + supportingText = if (nameError.isNotEmpty()) { + { Text(nameError, color = MaterialTheme.colorScheme.error) } + } else null, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // 가격 + OutlinedTextField( + value = price, + onValueChange = onPriceChange, + label = { Text("가격") }, + placeholder = { Text("0") }, + isError = priceError.isNotEmpty(), + supportingText = if (priceError.isNotEmpty()) { + { Text(priceError, color = MaterialTheme.colorScheme.error) } + } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // 카테고리 선택 + CategoryDropdown( + categories = categories, + selectedCategoryId = selectedCategory, + onCategorySelected = onCategoryChange, + error = categoryError, + modifier = Modifier.fillMaxWidth() + ) + + // 설명 + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + label = { Text("설명") }, + placeholder = { Text("메뉴 설명 입력") }, + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + maxLines = 4 + ) + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt b/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt index 1cf7d6f..ec60280 100644 --- a/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt +++ b/feature/menu/src/main/java/com/example/menu/screen/MenuMviScreen.kt @@ -1,55 +1,219 @@ package com.example.menu.screen -// feature/menu/src/main/java/com/barrion/feature/menu/screen/MenuMviScreen.kt - import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.GridView import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.menu.component.CategoryManagementCard +import com.example.menu.component.CategorySection +import com.example.menu.component.DeleteMenuDialog +import com.example.menu.type.MenuIntent +import com.example.menu.type.MenuState import com.example.menu.type.MenuEffect import com.example.menu.viewmodel.MenuViewModel +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors - +/** + * 메뉴 메인 화면 - 커스텀 디자인 시스템 적용 + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun MenuScreen() { - // ViewModel 없이 간단한 화면으로 변경 - Box( +fun MenuMviScreen( + viewModel: MenuViewModel, + onNavigateToCategoryManagement: () -> Unit = {}, + onNavigateToAddMenu: () -> Unit = {}, + onNavigateToCategoryDetail: (Long, String) -> Unit = { _, _ -> }, + onNavigateToEditMenu: (Long) -> Unit = {} +) { + // State 구독 + val state by viewModel.state.collectAsStateWithLifecycle() + + // 메뉴 삭제 다이얼로그 상태 + var showDeleteMenuDialog by remember { mutableStateOf(false) } + var menuToDelete by remember { mutableStateOf(null) } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is MenuEffect.NavigateToCategoryManagement -> onNavigateToCategoryManagement() + is MenuEffect.NavigateToAddMenu -> onNavigateToAddMenu() + is MenuEffect.NavigateToCategoryDetail -> { + onNavigateToCategoryDetail(effect.categoryId, effect.categoryName) + } + is MenuEffect.MenuDeletedSuccessfully -> { + showDeleteMenuDialog = false + menuToDelete = null + } + else -> {} + } + } + } + + // UI 구성 - 헤더 위치 조정 + Scaffold( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "🍽️", - style = MaterialTheme.typography.displayLarge - ) - Text( - text = "메뉴 관리", - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = "메뉴 등록, 수정, 삭제 화면", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "개발 예정", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.barrionColors.white, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "메뉴 관리", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.barrionColors.grayBlack + ) + }, + navigationIcon = { + IconButton( + onClick = { viewModel.handleIntent(MenuIntent.NavigateToCategoryManagement) } + ) { + Icon( + Icons.Default.GridView, + contentDescription = "카테고리 관리", + tint = MaterialTheme.barrionColors.primaryBlue + ) + } + }, + actions = { + IconButton( + onClick = { viewModel.handleIntent(MenuIntent.NavigateToAddMenu) } + ) { + Icon( + Icons.Default.Add, + contentDescription = "메뉴 추가", + tint = MaterialTheme.barrionColors.primaryBlue + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack, + navigationIconContentColor = MaterialTheme.barrionColors.primaryBlue, + actionIconContentColor = MaterialTheme.barrionColors.primaryBlue + ), + windowInsets = WindowInsets.statusBars // 상태바 inset 사용 ) } + ) { paddingValues -> + MenuContent( + state = state, + onIntent = viewModel::handleIntent, + onDeleteMenu = { menu -> + menuToDelete = menu + showDeleteMenuDialog = true + }, + onEditMenu = { menu -> + onNavigateToEditMenu(menu.id) + }, + modifier = Modifier.padding(paddingValues) + ) + + // 메뉴 삭제 확인 다이얼로그 + DeleteMenuDialog( + isVisible = showDeleteMenuDialog, + menuName = menuToDelete?.name ?: "", + onDismiss = { + showDeleteMenuDialog = false + menuToDelete = null + }, + onConfirm = { + menuToDelete?.let { menu -> + viewModel.handleIntent(MenuIntent.DeleteMenu(menu.id)) + } + } + ) } } -@Preview +/** + * 메뉴 화면 내용 컴포넌트 - 간격 및 색상 적용 + */ @Composable -private fun MenuScreenPreview() { - MaterialTheme { - MenuScreen() +private fun MenuContent( + state: MenuState, + onIntent: (MenuIntent) -> Unit, + onDeleteMenu: (com.example.domain.model.Menu) -> Unit, + onEditMenu: (com.example.domain.model.Menu) -> Unit, + modifier: Modifier = Modifier +) { + when { + state.isLoading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.barrionColors.primaryBlue + ) + } + } + + state.error != null -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.error + ) + Spacer(modifier = Modifier.height(Spacing.Medium)) + Button( + onClick = { onIntent(MenuIntent.RefreshData) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.primaryBlue + ) + ) { + Text( + text = "다시 시도", + color = MaterialTheme.barrionColors.white + ) + } + } + } + } + + else -> { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.Medium), + verticalArrangement = Arrangement.spacedBy(Spacing.Large) + ) { + // 카테고리 관리 카드 - 중앙 정렬 + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CategoryManagementCard( + onClick = { onIntent(MenuIntent.NavigateToCategoryManagement) }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // 카테고리별 메뉴 섹션들 + items(state.categories) { category -> + CategorySection( + category = category, + menus = state.getMenusForCategory(category.id), + onSeeMore = { onIntent(MenuIntent.NavigateToCategoryDetail(category.id)) }, + onAddMenu = { onIntent(MenuIntent.NavigateToAddMenu) }, + onDeleteMenu = onDeleteMenu, + onEditMenu = onEditMenu + ) + } + } + } } } \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt b/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt index 9ccca88..cdbf501 100644 --- a/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt +++ b/feature/menu/src/main/java/com/example/menu/type/MenuEffect.kt @@ -1,13 +1,30 @@ -// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuEffect.kt package com.example.menu.type -import android.view.MenuItem - - /** - * MVI Pattern - Side Effect - * 일회성 이벤트들 (Navigation, Toast, Dialog 등) + * 메뉴 화면에서 발생하는 일회성 이벤트들 + * - MVI 패턴에서 Side Effect를 나타내는 sealed class + * - 네비게이션, 토스트 메시지, 다이얼로그 등 일회성 액션 */ -sealed class MenuEffect { - data class ShowToast(val message: String) : MenuEffect() +sealed interface MenuEffect { + + // 네비게이션 관련 + object NavigateToCategoryManagement : MenuEffect + object NavigateToAddMenu : MenuEffect + data class NavigateToCategoryDetail(val categoryId: Long, val categoryName: String) : MenuEffect + data class NavigateToEditMenu(val menuId: Long) : MenuEffect + + // 메시지 표시 + data class ShowToast(val message: String) : MenuEffect + data class ShowError(val error: String) : MenuEffect + + // 다이얼로그 관련 + data class ShowDeleteMenuDialog(val menuId: Long, val menuName: String) : MenuEffect + data class ShowDeleteCategoryDialog(val categoryId: Long, val categoryName: String) : MenuEffect + + // 성공 메시지 + object MenuAddedSuccessfully : MenuEffect + object MenuUpdatedSuccessfully : MenuEffect + object MenuDeletedSuccessfully : MenuEffect + object CategoryAddedSuccessfully : MenuEffect + object CategoryDeletedSuccessfully : MenuEffect } \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt b/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt index 7423d06..9f2436e 100644 --- a/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt +++ b/feature/menu/src/main/java/com/example/menu/type/MenuIntent.kt @@ -1,15 +1,36 @@ -// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuIntent.kt - package com.example.menu.type -import android.view.MenuItem - /** - * MVI Pattern - Intent - * 사용자의 모든 행동과 시스템 이벤트를 나타내는 Intent들 + * 메뉴 화면에서 발생하는 모든 사용자 액션들 */ -sealed class MenuIntent { - object LoadMenuData : MenuIntent() - object RefreshMenuData : MenuIntent() - object ClearError : MenuIntent() +sealed interface MenuIntent { + + // 데이터 로드 + object LoadMenus : MenuIntent + object RefreshData : MenuIntent + + // 네비게이션 관련 + object NavigateToCategoryManagement : MenuIntent + object NavigateToAddMenu : MenuIntent + data class NavigateToCategoryDetail(val categoryId: Long) : MenuIntent + + // 메뉴 관련 액션 + data class AddMenu( + val name: String, + val price: Int, + val categoryId: Long, + val description: String = "", + val imageUrl: String = "", + val base64Image: String? = null // 추가 + ) : MenuIntent + + data class UpdateMenu(val menu: com.example.domain.model.Menu) : MenuIntent + data class DeleteMenu(val menuId: Long) : MenuIntent + + // 카테고리 관련 액션 + data class AddCategory(val name: String) : MenuIntent + data class DeleteCategory(val categoryId: Long) : MenuIntent + + // UI 상태 변경 + object ClearError : MenuIntent } \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/type/MenuState.kt b/feature/menu/src/main/java/com/example/menu/type/MenuState.kt index a8bd772..06961aa 100644 --- a/feature/menu/src/main/java/com/example/menu/type/MenuState.kt +++ b/feature/menu/src/main/java/com/example/menu/type/MenuState.kt @@ -1,14 +1,36 @@ -// feature/menu/src/main/java/com/barrion/feature/menu/type/MenuState.kt package com.example.menu.type -import android.view.MenuItem - +import com.example.domain.model.Menu +import com.example.domain.model.Category /** - * MVI Pattern - State - * UI의 모든 상태를 나타내는 불변 데이터 클래스 + * 메뉴 화면의 UI 상태를 나타내는 데이터 클래스 + * - MVI 패턴에서 화면에 표시될 모든 상태 정보 포함 */ data class MenuState( - val isLoading: Boolean = false, - val error: String? = null -) \ No newline at end of file + val isLoading: Boolean = false, // 로딩 상태 + val categories: List = emptyList(), // 카테고리 목록 + val menusByCategory: Map> = emptyMap(), // 카테고리별 메뉴 맵 + val error: String? = null, // 에러 메시지 + val isRefreshing: Boolean = false // 새로고침 상태 +) { + + /** + * 특정 카테고리의 메뉴 목록 조회 + * @param categoryId 카테고리 ID + * @return 해당 카테고리의 메뉴 리스트 (없으면 빈 리스트) + */ + fun getMenusForCategory(categoryId: Long): List { + return menusByCategory[categoryId] ?: emptyList() + } + + /** + * 메뉴가 있는 카테고리만 필터링 + * @return 메뉴가 1개 이상 있는 카테고리 리스트 + */ + fun getCategoriesWithMenus(): List { + return categories.filter { category -> + getMenusForCategory(category.id).isNotEmpty() + } + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt b/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt index 59d5de9..381196d 100644 --- a/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/example/menu/viewmodel/MenuViewModel.kt @@ -1,21 +1,42 @@ -// feature/menu/src/main/java/com/barrion/feature/menu/viewmodel/MenuViewModel.kt package com.example.menu.viewmodel -import android.view.MenuItem import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.menu.type.MenuEffect +import com.example.domain.usecase.menu.AddCategoryUseCase +import com.example.domain.usecase.menu.GetMenusUseCase +import com.example.domain.usecase.menu.AddMenuUseCase +import com.example.domain.usecase.menu.DeleteMenuUseCase +import com.example.domain.usecase.menu.UpdateMenuUseCase import com.example.menu.type.MenuIntent import com.example.menu.type.MenuState -import kotlinx.coroutines.flow.* +import com.example.menu.type.MenuEffect +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import javax.inject.Inject /** - * MVI Pattern ViewModel - * Intent를 받아서 State를 변경하고 Effect를 발생시킴 + * 메뉴 화면의 ViewModel - MVI 패턴 구현 */ +//class MenuViewModel( +// private val getMenusUseCase: GetMenusUseCase, +// private val addMenuUseCase: AddMenuUseCase, +// private val deleteMenuUseCase: DeleteMenuUseCase +//) : ViewModel() { -class MenuViewModel : ViewModel() { +@HiltViewModel //Hilt 추가 +class MenuViewModel @Inject constructor( + private val getMenusUseCase: GetMenusUseCase, + private val addMenuUseCase: AddMenuUseCase, + private val deleteMenuUseCase: DeleteMenuUseCase, + private val addCategoryUseCase: AddCategoryUseCase, // 추가 + private val updateMenuUseCase: UpdateMenuUseCase // 추가 +) : ViewModel() { private val _state = MutableStateFlow(MenuState()) val state: StateFlow = _state.asStateFlow() @@ -24,37 +45,183 @@ class MenuViewModel : ViewModel() { val effect: SharedFlow = _effect.asSharedFlow() init { - handleIntent(MenuIntent.LoadMenuData) + handleIntent(MenuIntent.LoadMenus) } fun handleIntent(intent: MenuIntent) { + println("Intent 받음: $intent") // 디버그 로그 추가 when (intent) { - is MenuIntent.LoadMenuData -> loadMenuData() - is MenuIntent.RefreshMenuData -> refreshMenuData() + is MenuIntent.LoadMenus -> loadMenus() + is MenuIntent.RefreshData -> refreshData() + is MenuIntent.NavigateToCategoryManagement -> { + println("카테고리 관리로 이동 Intent 처리") // 디버그 로그 추가 + navigateToCategoryManagement() + } + is MenuIntent.NavigateToAddMenu -> navigateToAddMenu() + is MenuIntent.NavigateToCategoryDetail -> navigateToCategoryDetail(intent.categoryId) + is MenuIntent.AddMenu -> addMenu(intent) + is MenuIntent.UpdateMenu -> updateMenu(intent.menu) // 추가 + is MenuIntent.DeleteMenu -> deleteMenu(intent.menuId) + is MenuIntent.AddCategory -> addCategory(intent.name) // 추가 + is MenuIntent.DeleteCategory -> deleteCategory(intent.categoryId) // 추가 is MenuIntent.ClearError -> clearError() + else -> { /* TODO: 나머지 구현 */ } + } + } +// 새로운 함수들 추가 + /** + * 카테고리 추가 + */ + // addCategory 함수 수정 + private fun addCategory(name: String) { + viewModelScope.launch { + println("📁 ViewModel - 카테고리 추가 시작: $name") + + addCategoryUseCase.execute(name) + .onSuccess { category -> + println("✅ ViewModel - 카테고리 추가 성공: $category") + _effect.emit(MenuEffect.CategoryAddedSuccessfully) + _effect.emit(MenuEffect.ShowToast("카테고리가 추가되었습니다")) + loadMenus() + } + .onFailure { exception -> + println("❌ ViewModel - 카테고리 추가 실패: ${exception.message}") + _effect.emit(MenuEffect.ShowError( + exception.message ?: "카테고리 추가 중 오류가 발생했습니다" + )) + } + } + } + + /** + * 카테고리 삭제 + */ + private fun deleteCategory(categoryId: Long) { + viewModelScope.launch { + // TODO: DeleteCategoryUseCase 구현 후 사용 + // 임시로 성공 처리 + _effect.emit(MenuEffect.CategoryDeletedSuccessfully) + _effect.emit(MenuEffect.ShowToast("카테고리가 삭제되었습니다")) + // 데이터 다시 로드 + loadMenus() } } - private fun loadMenuData() { + private fun loadMenus() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) - try { - // 임시 로딩 시뮬레이션 - kotlinx.coroutines.delay(1000) - _state.value = _state.value.copy(isLoading = false) - } catch (e: Exception) { - _state.value = _state.value.copy( - isLoading = false, - error = "메뉴 데이터를 불러올 수 없습니다" - ) - } + getMenusUseCase.execute() + .onSuccess { (categories, menusByCategory) -> + _state.value = _state.value.copy( + isLoading = false, + categories = categories, + menusByCategory = menusByCategory + ) + } + .onFailure { exception -> + _state.value = _state.value.copy( + isLoading = false, + error = exception.message ?: "데이터 로드 중 오류가 발생했습니다" + ) + } + } + } + + private fun refreshData() { + viewModelScope.launch { + _state.value = _state.value.copy(isRefreshing = true) + + getMenusUseCase.execute() + .onSuccess { (categories, menusByCategory) -> + _state.value = _state.value.copy( + isRefreshing = false, + categories = categories, + menusByCategory = menusByCategory + ) + } + .onFailure { exception -> + _state.value = _state.value.copy( + isRefreshing = false, + error = exception.message ?: "새로고침 중 오류가 발생했습니다" + ) + } + } + } + + private fun addMenu(intent: MenuIntent.AddMenu) { + viewModelScope.launch { + println("📱 ViewModel - 메뉴 추가 요청") + println("📱 메뉴 이름: ${intent.name}") + println("📱 base64 이미지: ${intent.base64Image?.take(50) ?: "❌ NULL"}...") + + addMenuUseCase.execute( + name = intent.name, + price = intent.price, + categoryId = intent.categoryId, + description = intent.description, + base64Image = intent.base64Image + ) + } + } +// updateMenu 함수 추가 + /** + * 메뉴 수정 + */ + // updateMenu 함수 수정 + private fun updateMenu(menu: com.example.domain.model.Menu) { + viewModelScope.launch { + updateMenuUseCase.execute(menu) + .onSuccess { updatedMenu -> + _effect.emit(MenuEffect.MenuUpdatedSuccessfully) + _effect.emit(MenuEffect.ShowToast("메뉴가 수정되었습니다")) + loadMenus() // 데이터 새로고침 + } + .onFailure { exception -> + _effect.emit(MenuEffect.ShowError( + exception.message ?: "메뉴 수정 중 오류가 발생했습니다" + )) + } + } + } + private fun deleteMenu(menuId: Long) { + viewModelScope.launch { + deleteMenuUseCase.execute(menuId) + .onSuccess { + _effect.emit(MenuEffect.MenuDeletedSuccessfully) + loadMenus() + } + .onFailure { exception -> + _effect.emit(MenuEffect.ShowError( + exception.message ?: "메뉴 삭제 중 오류가 발생했습니다" + )) + } + } + } + + private fun navigateToCategoryManagement() { + viewModelScope.launch { + println("NavigateToCategoryManagement Effect 발생") // 디버그 로그 추가 + _effect.emit(MenuEffect.NavigateToCategoryManagement) + } + } + + private fun navigateToAddMenu() { + viewModelScope.launch { + _effect.emit(MenuEffect.NavigateToAddMenu) } } - private fun refreshMenuData() = loadMenuData() + private fun navigateToCategoryDetail(categoryId: Long) { + viewModelScope.launch { + val categoryName = _state.value.categories + .find { it.id == categoryId }?.name ?: "" + + _effect.emit(MenuEffect.NavigateToCategoryDetail(categoryId, categoryName)) + } + } private fun clearError() { _state.value = _state.value.copy(error = null) } -} \ No newline at end of file +} diff --git a/feature/order/build.gradle.kts b/feature/order/build.gradle.kts index abceda7..9170719 100644 --- a/feature/order/build.gradle.kts +++ b/feature/order/build.gradle.kts @@ -57,6 +57,8 @@ android { dependencies { // 코어 UI 모듈 의존성 implementation(project(":core:ui")) + implementation(project(":domain")) + implementation(project(":core:common")) // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) implementation("androidx.hilt:hilt-navigation-compose:1.1.0") diff --git a/feature/order/src/main/java/com/example/order/component/OrderListItem.kt b/feature/order/src/main/java/com/example/order/component/OrderListItem.kt new file mode 100644 index 0000000..daf6886 --- /dev/null +++ b/feature/order/src/main/java/com/example/order/component/OrderListItem.kt @@ -0,0 +1,133 @@ +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.domain.model.Order +import com.example.domain.model.OrderStatus +import com.example.ui.theme.barrionColors +import java.text.NumberFormat +import java.util.* + +@Composable +fun OrderListItem( + order: Order, + onDeleteClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val formatter = NumberFormat.getNumberInstance(Locale.KOREA) + + // 환불 여부 판단 (음수 금액 = 환불) + val isRefunded = order.totalAmount < 0 + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 주문번호와 상태칩 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "주문번호 ${order.orderId}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.grayBlack + ) + + OrderStatusChip(status = order.status) + } + + // 주문 시간 + Text( + text = "주문시각: ${order.orderTime}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + + // 주문 금액 + Text( + text = "주문금액: ${formatter.format(order.totalAmount)}원", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isRefunded) { + MaterialTheme.barrionColors.error + } else { + MaterialTheme.barrionColors.primaryBlue + } + ) + + // 취소 버튼 (환불된 주문에는 표시하지 않음) + if (!isRefunded) { + Button( + onClick = { onDeleteClick(order.orderId) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.barrionColors.error + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "주문 취소", + color = MaterialTheme.barrionColors.white, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + +@Preview(apiLevel = 33, showBackground = true) +@Composable +fun OrderListItemPreview() { + // 일반 주문 예시 + val sampleOrder = Order( + orderId = 1001, + storeId = 1, + orderTime = "2025.05.09 10:25", + totalAmount = 12500, + status = OrderStatus.RECEIVED + ) + + OrderListItem( + order = sampleOrder, + onDeleteClick = { } + ) +} + +@Preview(apiLevel = 33, showBackground = true) +@Composable +fun OrderListItemRefundPreview() { + // 환불 주문 예시 + val refundOrder = Order( + orderId = 1002, + storeId = 1, + orderTime = "2025.05.09 09:15", + totalAmount = -8500, + status = OrderStatus.CANCELLED + ) + + OrderListItem( + order = refundOrder, + onDeleteClick = { } + ) +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/component/OrderStatusChip.kt b/feature/order/src/main/java/com/example/order/component/OrderStatusChip.kt new file mode 100644 index 0000000..96d99b6 --- /dev/null +++ b/feature/order/src/main/java/com/example/order/component/OrderStatusChip.kt @@ -0,0 +1,40 @@ +// ui/components/OrderStatusChip.kt +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.domain.model.OrderStatus +import com.example.ui.theme.barrionColors + +@Composable +fun OrderStatusChip( + status: OrderStatus, + modifier: Modifier = Modifier +) { + val (backgroundColor, textColor) = when (status.colorType) { + "blue" -> MaterialTheme.barrionColors.primaryBlue to MaterialTheme.barrionColors.white + "gray" -> MaterialTheme.barrionColors.grayMedium to MaterialTheme.barrionColors.white + "red" -> MaterialTheme.barrionColors.error to MaterialTheme.barrionColors.white + else -> MaterialTheme.barrionColors.primaryBlue to MaterialTheme.barrionColors.white + } + + Text( + text = status.displayName, + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(backgroundColor) + .padding(horizontal = 10.dp, vertical = 5.dp), + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) +} diff --git a/feature/order/src/main/java/com/example/order/component/OrderSummaryCard.kt b/feature/order/src/main/java/com/example/order/component/OrderSummaryCard.kt new file mode 100644 index 0000000..e619dfd --- /dev/null +++ b/feature/order/src/main/java/com/example/order/component/OrderSummaryCard.kt @@ -0,0 +1,100 @@ +// ui/components/OrderSummaryCard.kt +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.domain.model.OrderSummary +import com.example.ui.theme.barrionColors +import com.example.ui.theme.Spacing +import com.example.ui.theme.CornerRadius +import java.text.NumberFormat +import java.util.* + +@Composable +fun OrderSummaryCard( + summary: OrderSummary, + modifier: Modifier = Modifier +) { + val formatter = NumberFormat.getNumberInstance(Locale.KOREA) + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(CornerRadius.Large), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + Column( + modifier = Modifier.padding(Spacing.Large) + ) { + Text( + text = "정산 현황", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(Spacing.Medium)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom + ) { + // 왼쪽: 완료/전체 건수 + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.Large) + ) { + Column { + Text( + text = "결제완료", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.barrionColors.grayMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${summary.completedCount}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Bold + ) + } + + Column { + Text( + text = "합계", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.barrionColors.grayMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${summary.totalCount}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Bold + ) + } + } + + // 오른쪽: 총 금액 + Text( + text = "${formatter.format(summary.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.barrionColors.primaryBlue, + fontWeight = FontWeight.Bold + ) + } + } + } +} \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt b/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt index f9264ba..1d90a75 100644 --- a/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt +++ b/feature/order/src/main/java/com/example/order/screen/OrderScreen.kt @@ -1,73 +1,224 @@ -package com.example.order.screen - -// feature/order/src/main/java/com/barrion/feature/order/screen/OrderScreen.kt - +// ui/screen/OrderScreen.kt +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.order.type.OrderEffect +import com.example.order.type.OrderIntent import com.example.order.viewmodel.OrderViewModel - +import com.example.ui.theme.barrionColors +import com.example.ui.theme.Spacing +import com.example.ui.theme.CornerRadius +import com.example.ui.components.buttons.BarrionNavigationButtons @Composable fun OrderScreen( - viewModel: OrderViewModel = viewModel() + viewModel: OrderViewModel, + modifier: Modifier = Modifier ) { - val state by viewModel.state.collectAsState() + val state by viewModel.state.collectAsStateWithLifecycle() + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + var orderToDelete by rememberSaveable { mutableIntStateOf(0) } // Effect 처리 LaunchedEffect(viewModel.effect) { viewModel.effect.collect { effect -> when (effect) { - is OrderEffect.ShowToast -> { - // Toast 처리 + is OrderEffect.ShowError -> { + // 에러 스낵바 또는 토스트 처리 } - is OrderEffect.NavigateToOrderDetail -> { - // Navigation 처리 + is OrderEffect.ShowDeleteSuccess -> { + // 성공 메시지 처리 } + else -> Unit } } } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.barrionColors.white) + .padding(Spacing.Medium) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "📋", - style = MaterialTheme.typography.displayLarge - ) - Text( - text = "주문 관리", - style = MaterialTheme.typography.headlineMedium + // 상단 헤더 - 주문 관리만 가운데 표시 + Text( + text = "주문 관리", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.barrionColors.grayBlack, + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) + .padding(bottom = Spacing.Large) + ) + + // 정산 현황 카드 + if (state.summary != null) { + OrderSummaryCard( + summary = state.summary!!, + modifier = Modifier.fillMaxWidth() ) + + Spacer(modifier = Modifier.height(Spacing.Large)) + } + + // 주문 내역 제목 - 왼쪽 배치로 변경 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 왼쪽: 주문 내역 제목 Text( - text = "실시간 주문 접수 및 처리 화면", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "주문 내역", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Bold ) + + // 오른쪽: 총 건수 Text( - text = "개발 예정", + text = "총 ${state.orders.size}건", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.barrionColors.grayMedium ) } + + Spacer(modifier = Modifier.height(Spacing.Medium)) + + // 주문 목록 + when { + state.isLoading -> { + OrderLoadingState() + } + + state.isEmpty -> { + OrderEmptyState() + } + + else -> { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(Spacing.Medium), + modifier = Modifier.weight(1f) + ) { + items(state.orders) { order -> + OrderListItem( + order = order, + onDeleteClick = { orderId -> + orderToDelete = orderId + showDeleteDialog = true + } + ) + } + } + } + } + } + + // 삭제 확인 다이얼로그 + if (showDeleteDialog) { + OrderDeleteDialog( + orderNumber = orderToDelete.toString(), + onConfirm = { + viewModel.handleIntent(OrderIntent.DeleteOrder(orderToDelete)) + showDeleteDialog = false + }, + onDismiss = { + showDeleteDialog = false + } + ) } } -@Preview @Composable -private fun OrderScreenPreview() { - MaterialTheme { - OrderScreen() +private fun OrderLoadingState( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(Spacing.XXLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.barrionColors.primaryBlue + ) + + Spacer(modifier = Modifier.height(Spacing.Medium)) + + Text( + text = "주문 내역을 불러오는 중...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } +} + +@Composable +private fun OrderEmptyState( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(Spacing.XXLarge), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "주문 내역이 없습니다", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + + Spacer(modifier = Modifier.height(Spacing.XSmall)) + + Text( + text = "새로운 주문이 들어오면 여기에 표시됩니다", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) } +} + +@Composable +private fun OrderDeleteDialog( + orderNumber: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + containerColor = MaterialTheme.barrionColors.white, + shape = RoundedCornerShape(CornerRadius.Large), + title = { + Text( + text = "${orderNumber}번을 취소처리 하시겠습니까?", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayBlack, + modifier = Modifier.padding(bottom = Spacing.Medium) + ) + }, + confirmButton = { + BarrionNavigationButtons( + leftText = "아니오", + rightText = "예", + onLeftClick = onDismiss, + onRightClick = onConfirm, + modifier = Modifier.fillMaxWidth() + ) + } + ) } \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderEffect.kt b/feature/order/src/main/java/com/example/order/type/OrderEffect.kt index e51344d..14a753b 100644 --- a/feature/order/src/main/java/com/example/order/type/OrderEffect.kt +++ b/feature/order/src/main/java/com/example/order/type/OrderEffect.kt @@ -1,6 +1,8 @@ package com.example.order.type +// feature/order/OrderEffect.kt sealed class OrderEffect { - data class ShowToast(val message: String) : OrderEffect() - data class NavigateToOrderDetail(val orderId: String) : OrderEffect() + data class ShowError(val message: String) : OrderEffect() + data class ShowDeleteSuccess(val orderNumber: String) : OrderEffect() + object NavigateToOrderDetail : OrderEffect() } \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderIntent.kt b/feature/order/src/main/java/com/example/order/type/OrderIntent.kt index 4a2942f..2a3cdae 100644 --- a/feature/order/src/main/java/com/example/order/type/OrderIntent.kt +++ b/feature/order/src/main/java/com/example/order/type/OrderIntent.kt @@ -1,10 +1,7 @@ package com.example.order.type - sealed class OrderIntent { object LoadOrders : OrderIntent() - object RefreshOrders : OrderIntent() - data class SelectOrder(val orderId: String) : OrderIntent() - data class UpdateOrderStatus(val orderId: String, val status: OrderStatus) : OrderIntent() - object ClearError : OrderIntent() + data class DeleteOrder(val orderId: Int) : OrderIntent() + object RefreshData : OrderIntent() } \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/type/OrderState.kt b/feature/order/src/main/java/com/example/order/type/OrderState.kt index a6ec1c4..4bc5d53 100644 --- a/feature/order/src/main/java/com/example/order/type/OrderState.kt +++ b/feature/order/src/main/java/com/example/order/type/OrderState.kt @@ -1,39 +1,29 @@ package com.example.order.type -import java.time.LocalDateTime +import com.example.domain.model.Order +import com.example.domain.model.OrderSummary data class OrderState( val orders: List = emptyList(), - val selectedOrder: Order? = null, + val summary: OrderSummary = OrderSummary(0, 0, 0), val isLoading: Boolean = false, val error: String? = null ) { - val pendingOrders: List - get() = orders.filter { it.status == OrderStatus.PENDING } + // 주문 목록이 비어있는지 확인 + val isEmpty: Boolean + get() = !isLoading && orders.isEmpty() - val preparingOrders: List - get() = orders.filter { it.status == OrderStatus.PREPARING } + // 주문 목록에서 자동으로 요약 정보 계산 + fun calculateSummary(): OrderSummary { + val validOrders = orders.filter { it.totalAmount > 0 } // 환불 제외 + val completedCount = validOrders.size + val totalCount = orders.size + val totalAmount = validOrders.sumOf { it.totalAmount } - val readyOrders: List - get() = orders.filter { it.status == OrderStatus.READY } -} - -data class Order( - val id: String, - val tableNumber: Int, - val items: List, - val status: OrderStatus, - val totalAmount: Int, - val createdAt: LocalDateTime -) - -data class OrderItem( - val menuItemId: String, - val menuItemName: String, - val quantity: Int, - val unitPrice: Int -) - -enum class OrderStatus { - PENDING, PREPARING, READY, COMPLETED, CANCELLED + return OrderSummary( + completedCount = completedCount, + totalCount = totalCount, + totalAmount = totalAmount + ) + } } \ No newline at end of file diff --git a/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt b/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt index d17b472..00e5bbf 100644 --- a/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt +++ b/feature/order/src/main/java/com/example/order/viewmodel/OrderViewModel.kt @@ -1,19 +1,25 @@ package com.example.order.viewmodel -// feature/order/src/main/java/com/barrion/feature/order/viewmodel/OrderViewModel.kt - - import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.order.type.Order +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import android.util.Log +import com.example.domain.usecase.order.DeleteOrderUseCase +import com.example.domain.usecase.order.GetAllOrdersUseCase +import com.example.domain.usecase.order.GetOrderUseCase import com.example.order.type.OrderEffect import com.example.order.type.OrderIntent import com.example.order.type.OrderState -import com.example.order.type.OrderStatus -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject -class OrderViewModel : ViewModel() { +@HiltViewModel +class OrderViewModel @Inject constructor( + private val getAllOrdersUseCase: GetAllOrdersUseCase, + private val deleteOrderUseCase: DeleteOrderUseCase, + private val getOrderUseCase: GetOrderUseCase +) : ViewModel() { private val _state = MutableStateFlow(OrderState()) val state: StateFlow = _state.asStateFlow() @@ -21,72 +27,83 @@ class OrderViewModel : ViewModel() { private val _effect = MutableSharedFlow() val effect: SharedFlow = _effect.asSharedFlow() + // 현재 매장 ID (추후 설정 가능하도록) + private val currentStoreId = 0 // 임시값 + init { + Log.d("OrderViewModel", "🚀 ViewModel 초기화") + // 실제 API 호출 handleIntent(OrderIntent.LoadOrders) } fun handleIntent(intent: OrderIntent) { + Log.d("OrderViewModel", "📩 Intent 수신: $intent") + when (intent) { is OrderIntent.LoadOrders -> loadOrders() - is OrderIntent.RefreshOrders -> refreshOrders() - is OrderIntent.SelectOrder -> selectOrder(intent.orderId) - is OrderIntent.UpdateOrderStatus -> updateOrderStatus(intent.orderId, intent.status) - is OrderIntent.ClearError -> clearError() + is OrderIntent.DeleteOrder -> deleteOrder(intent.orderId) + is OrderIntent.RefreshData -> refreshData() } } private fun loadOrders() { viewModelScope.launch { + Log.d("OrderViewModel", "📋 주문 목록 로딩 시작 (매장 ID: $currentStoreId)") _state.value = _state.value.copy(isLoading = true, error = null) - try { - // 임시 데이터 - val orders = getSampleOrders() - _state.value = _state.value.copy( - isLoading = false, - orders = orders - ) - } catch (e: Exception) { - _state.value = _state.value.copy( - isLoading = false, - error = "주문 데이터를 불러올 수 없습니다" - ) - } - } - } + getAllOrdersUseCase(currentStoreId) + .onSuccess { orders -> + Log.d("OrderViewModel", "✅ 주문 목록 로딩 성공: ${orders.size}개") - private fun refreshOrders() = loadOrders() + val newState = _state.value.copy( + isLoading = false, + orders = orders, + error = null + ) - private fun selectOrder(orderId: String) { - val order = _state.value.orders.find { it.id == orderId } - _state.value = _state.value.copy(selectedOrder = order) + // 요약 정보 자동 계산 + val summary = newState.calculateSummary() - viewModelScope.launch { - _effect.emit(OrderEffect.NavigateToOrderDetail(orderId)) - } - } + _state.value = newState.copy(summary = summary) - private fun updateOrderStatus(orderId: String, status: OrderStatus) { - _state.value = _state.value.copy( - orders = _state.value.orders.map { order -> - if (order.id == orderId) { - order.copy(status = status) - } else { - order + Log.d("OrderViewModel", "📊 요약 정보 계산 완료: $summary") + } + .onFailure { error -> + Log.e("OrderViewModel", "❌ 주문 목록 로딩 실패: ${error.message}") + _state.value = _state.value.copy( + isLoading = false, + error = error.message + ) + _effect.emit(OrderEffect.ShowError("주문 목록을 불러올 수 없습니다")) } - } - ) + } + } + private fun deleteOrder(orderId: Int) { viewModelScope.launch { - _effect.emit(OrderEffect.ShowToast("주문 상태가 업데이트되었습니다")) + Log.d("OrderViewModel", "🗑️ 주문 삭제 시작: ID $orderId") + + deleteOrderUseCase(orderId) + .onSuccess { + Log.d("OrderViewModel", "✅ 주문 삭제 성공: ID $orderId") + _effect.emit(OrderEffect.ShowDeleteSuccess("주문이 삭제되었습니다")) + // 삭제 후 데이터 새로고침 + refreshData() + } + .onFailure { error -> + Log.e("OrderViewModel", "❌ 주문 삭제 실패: ${error.message}") + _effect.emit(OrderEffect.ShowError("주문 삭제에 실패했습니다")) + } } } - private fun clearError() { - _state.value = _state.value.copy(error = null) + private fun refreshData() { + Log.d("OrderViewModel", "🔄 데이터 새로고침") + loadOrders() } - private fun getSampleOrders(): List { - return emptyList() // 일단 빈 리스트 + // 매장 ID 설정 함수 (추후 사용) + fun setStoreId(storeId: Int) { + // TODO: storeId 설정 후 데이터 새로고침 } } \ No newline at end of file diff --git a/feature/sales/build.gradle.kts b/feature/sales/build.gradle.kts index da26c60..28c8918 100644 --- a/feature/sales/build.gradle.kts +++ b/feature/sales/build.gradle.kts @@ -57,7 +57,8 @@ android { dependencies { // 코어 UI 모듈 의존성 implementation(project(":core:ui")) - + implementation(project(":domain")) + implementation(project(":core:common")) // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") diff --git a/feature/sales/src/main/java/com/example/sales/component/SalesBarChart.kt b/feature/sales/src/main/java/com/example/sales/component/SalesBarChart.kt new file mode 100644 index 0000000..a702376 --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/component/SalesBarChart.kt @@ -0,0 +1,194 @@ +// feature/sales/component/SalesBarChart.kt +package com.barrion.pos.feature.sales.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.domain.model.ChartData +import com.example.ui.theme.BarrionTheme +import com.example.ui.theme.barrionColors +import java.text.NumberFormat +import java.util.Locale + +/** + * 월별 매출 바 차트 + */ +@Composable +fun SalesBarChart( + chartData: List, + onMonthClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val maxSales = chartData.maxOfOrNull { it.sales } ?: 1L + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 차트 제목 + Text( + text = "월별 매출 현황", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.barrionColors.grayBlack + ) + + // 바 차트 + Row( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.Bottom + ) { + chartData.forEach { data -> + BarItem( + month = data.month, + sales = data.sales, + maxSales = maxSales, + isHighlight = data.isHighlight, + onClick = { onMonthClick(data.month) }, + modifier = Modifier.weight(1f) + ) + } + } + + // 월 레이블 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + (1..12).forEach { month -> + Text( + text = "${month}월", + fontSize = 10.sp, + color = MaterialTheme.barrionColors.grayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +/** + * 개별 바 아이템 + */ +@Composable +private fun BarItem( + month: Int, + sales: Long, + maxSales: Long, + isHighlight: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val barHeight = if (maxSales > 0) { + ((sales.toFloat() / maxSales.toFloat()) * 180f).dp + } else { + 4.dp // 최소 높이 + } + + val barColor = if (isHighlight) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.blueLighter + } + + Column( + modifier = modifier + .clickable { onClick() } + .padding(horizontal = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 매출 금액 표시 (높은 바만) + if (sales > 0 && barHeight > 50.dp) { + Text( + text = formatShortCurrency(sales), + fontSize = 8.sp, + color = MaterialTheme.barrionColors.grayMedium, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + // 바 + Box( + modifier = Modifier + .width(20.dp) + .height(barHeight.coerceAtLeast(4.dp)) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .background(barColor) + ) + } +} + +/** + * 숫자를 짧은 통화 형식으로 포맷 (예: 1.2M, 500K) + */ +private fun formatShortCurrency(amount: Long): String { + return when { + amount >= 100_000_000 -> "${amount / 100_000_000}억" + amount >= 10_000_000 -> "${amount / 10_000_000}천만" + amount >= 1_000_000 -> "${amount / 1_000_000}백만" + amount >= 10_000 -> "${amount / 10_000}만" + else -> { + val formatter = NumberFormat.getNumberInstance(Locale.KOREA) + formatter.format(amount) + } + } +} + +@Preview(apiLevel = 33, showBackground = true) +@Composable +private fun SalesBarChartPreview() { + BarrionTheme { + SalesBarChart( + chartData = listOf( + ChartData(1, 2_500_000, false), + ChartData(2, 3_200_000, false), + ChartData(3, 4_100_000, true), // 하이라이트 + ChartData(4, 3_800_000, false), + ChartData(5, 4_500_000, false), + ChartData(6, 3_900_000, false), + ChartData(7, 5_200_000, false), + ChartData(8, 4_800_000, false), + ChartData(9, 4_300_000, false), + ChartData(10, 3_700_000, false), + ChartData(11, 4_000_000, false), + ChartData(12, 5_500_000, false) + ), + onMonthClick = { } + ) + } +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/component/SalesSummaryCard.kt b/feature/sales/src/main/java/com/example/sales/component/SalesSummaryCard.kt new file mode 100644 index 0000000..bb2fab2 --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/component/SalesSummaryCard.kt @@ -0,0 +1,148 @@ +// feature/sales/component/SalesSummaryCard.kt +package com.barrion.pos.feature.sales.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.domain.model.SalesSummary +import com.example.ui.theme.BarrionTheme +import com.example.ui.theme.barrionColors +import java.text.NumberFormat +import java.util.Locale + +/** + * 매출 요약 정보 카드 + */ +@Composable +fun SalesSummaryCard( + summary: SalesSummary, + selectedYear: Int = 2025, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 제목 + Text( + text = "${selectedYear}년 매출 요약", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Bold + ) + + // 첫 번째 행: 총 매출, 월 평균 매출 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SummaryItem( + title = "총 매출", + value = formatCurrency(summary.totalSales), + modifier = Modifier.weight(1f) + ) + + SummaryItem( + title = "월 평균 매출", + value = formatCurrency(summary.averageMonthlySales), + modifier = Modifier.weight(1f) + ) + } + + // 두 번째 행: 최고 매출 월, 최저 매출 월 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SummaryItem( + title = "최고 매출 월", + value = "${summary.highestSalesMonth}월", + modifier = Modifier.weight(1f) + ) + + SummaryItem( + title = "최저 매출 월", + value = "${summary.lowestSalesMonth}월", + modifier = Modifier.weight(1f) + ) + } + } + } +} + +/** + * 요약 정보 아이템 + */ +@Composable +private fun SummaryItem( + title: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.Start + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMediumDark + ) + + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.SemiBold + ) + } +} + +/** + * 숫자를 통화 형식으로 포맷 + */ +private fun formatCurrency(amount: Long): String { + val formatter = NumberFormat.getNumberInstance(Locale.KOREA) + return "${formatter.format(amount)}원" +} + + +@Preview(apiLevel = 33, showBackground = true) +@Composable +private fun SalesSummaryCardPreview() { + BarrionTheme { + SalesSummaryCard( + summary = SalesSummary( + totalSales = 45_400_000, + averageMonthlySales = 3_783_333, + highestSalesMonth = 12, + lowestSalesMonth = 1, + highestSalesAmount = 5_200_000, + lowestSalesAmount = 2_100_000 + ), + selectedYear = 2025 + ) + } +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/component/YearSelector.kt b/feature/sales/src/main/java/com/example/sales/component/YearSelector.kt new file mode 100644 index 0000000..f8f0cf0 --- /dev/null +++ b/feature/sales/src/main/java/com/example/sales/component/YearSelector.kt @@ -0,0 +1,116 @@ +package com.example.sales.component + +import com.example.ui.theme.BarrionTheme + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.ui.theme.barrionColors + + +/** + * 연도 선택기 컴포넌트 + */ +@Composable +fun YearSelector( + selectedYear: Int, + onYearChange: (Int) -> Unit, + modifier: Modifier = Modifier, + minYear: Int = 2020, + maxYear: Int = 2030 +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // 이전 연도 버튼 + IconButton( + onClick = { + if (selectedYear > minYear) { + onYearChange(selectedYear - 1) + } + }, + enabled = selectedYear > minYear + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowLeft, + contentDescription = "이전 연도", + tint = if (selectedYear > minYear) { + MaterialTheme.barrionColors.grayBlack + } else { + MaterialTheme.barrionColors.grayMedium + } + ) + } + + // 연도 표시 + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.barrionColors.primaryBlue) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${selectedYear}년", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.barrionColors.white + ) + } + + // 다음 연도 버튼 + IconButton( + onClick = { + if (selectedYear < maxYear) { + onYearChange(selectedYear + 1) + } + }, + enabled = selectedYear < maxYear + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = "다음 연도", + tint = if (selectedYear < maxYear) { + MaterialTheme.barrionColors.grayBlack + } else { + MaterialTheme.barrionColors.grayMedium + } + ) + } + } +} + + + +@Preview(apiLevel = 33, showBackground = true) +@Composable +private fun YearSelectorPreview() { + BarrionTheme { + YearSelector( + selectedYear = 2025, + onYearChange = { } + ) + } +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt b/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt index e259136..3d85361 100644 --- a/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt +++ b/feature/sales/src/main/java/com/example/sales/screen/SalesScreen.kt @@ -4,53 +4,358 @@ package com.example.sales.screen import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.viewmodel.compose.viewModel +import com.barrion.pos.feature.sales.component.SalesSummaryCard +import com.example.domain.model.ChartData +import com.example.domain.model.SalesSummary +import com.example.sales.component.YearSelector import com.example.sales.type.SalesEffect +import com.example.sales.type.SalesIntent +import com.example.sales.type.SalesState import com.example.sales.viewmodel.SalesViewModel +import com.example.ui.theme.BarrionTheme +import com.example.ui.theme.barrionColors +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.barrion.pos.feature.sales.component.SalesBarChart +/** + * 매출 관리 화면 + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SalesScreen() { +fun SalesScreen( + viewModel: SalesViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Effect 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is SalesEffect.ShowError -> { + snackbarHostState.showSnackbar(effect.message) + } + is SalesEffect.ShowSuccess -> { + snackbarHostState.showSnackbar(effect.message) + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "매출 관리", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + }, + actions = { + IconButton( + onClick = { viewModel.handleIntent(SalesIntent.RefreshData) } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "새로고침" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack, + actionIconContentColor = MaterialTheme.barrionColors.grayBlack + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = MaterialTheme.barrionColors.white + ) { paddingValues -> + SalesContent( + state = state, + onIntent = viewModel::handleIntent, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + } +} + +/** + * 매출 화면 컨텐츠 + */ +@Composable +private fun SalesContent( + state: SalesState, + onIntent: (SalesIntent) -> Unit, + modifier: Modifier = Modifier +) { + when { + state.isLoading -> { + LoadingContent(modifier = modifier) + } + state.isEmpty -> { + EmptyContent( + onRefresh = { onIntent(SalesIntent.RefreshData) }, + modifier = modifier + ) + } + state.error != null -> { + ErrorContent( + error = state.error, + onRefresh = { onIntent(SalesIntent.RefreshData) }, + modifier = modifier + ) + } + else -> { + SalesDataContent( + state = state, + onIntent = onIntent, + modifier = modifier + ) + } + } +} + +/** + * 로딩 상태 컨텐츠 + */ +@Composable +private fun LoadingContent( + modifier: Modifier = Modifier +) { Box( - modifier = Modifier.fillMaxSize(), + modifier = modifier, contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { + CircularProgressIndicator( + color = MaterialTheme.barrionColors.primaryBlue + ) Text( - text = "📊", - style = MaterialTheme.typography.displayLarge + text = "매출 데이터 로딩 중...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } + } +} + +/** + * 빈 상태 컨텐츠 + */ +@Composable +private fun EmptyContent( + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "매출 데이터가 없습니다", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.grayMedium, + textAlign = TextAlign.Center ) Text( - text = "매출 관리", - style = MaterialTheme.typography.headlineMedium + text = "새로고침을 시도해보세요", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium, + textAlign = TextAlign.Center ) + + Button( + onClick = onRefresh, + modifier = Modifier.padding(top = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text("새로고침") + } + } + } +} + +/** + * 에러 상태 컨텐츠 + */ +@Composable +private fun ErrorContent( + error: String, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { Text( - text = "일별, 월별 매출 통계 및 분석", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "데이터를 불러올 수 없습니다", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.barrionColors.error, + textAlign = TextAlign.Center ) Text( - text = "개발 예정", + text = error, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.barrionColors.grayMedium, + textAlign = TextAlign.Center + ) + + Button( + onClick = onRefresh, + modifier = Modifier.padding(top = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text("다시 시도") + } + } + } +} + +/** + * 매출 데이터 컨텐츠 + */ +@Composable +private fun SalesDataContent( + state: SalesState, + onIntent: (SalesIntent) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .padding(bottom = 16.dp), // 추가 바텀 패딩 + verticalArrangement = Arrangement.spacedBy(16.dp) // 간격 조정 + ) { + // 연도 선택기 + YearSelector( + selectedYear = state.selectedYear, + onYearChange = { year -> onIntent(SalesIntent.SelectYear(year)) }, + modifier = Modifier.fillMaxWidth() + ) + + // 매출 요약 카드 + state.salesSummary?.let { summary -> + SalesSummaryCard( + summary = summary, + selectedYear = state.selectedYear + ) + } + + // 월별 매출 차트 + if (state.chartData.isNotEmpty()) { + SalesBarChart( + chartData = state.chartData, + onMonthClick = { month -> onIntent(SalesIntent.SelectMonth(month)) } ) } } } -@Preview + +@Preview(apiLevel = 33, showBackground = true) @Composable private fun SalesScreenPreview() { - MaterialTheme { - SalesScreen() + BarrionTheme { + SalesDataContent( + state = SalesState( + isLoading = false, + selectedYear = 2025, + salesSummary = SalesSummary( + totalSales = 45_400_000, + averageMonthlySales = 3_783_333, + highestSalesMonth = 12, + lowestSalesMonth = 1, + highestSalesAmount = 5_200_000, + lowestSalesAmount = 2_100_000 + ), + chartData = listOf( + ChartData(1, 2_500_000, false), + ChartData(2, 3_200_000, false), + ChartData(3, 4_100_000, true), + ChartData(4, 3_800_000, false), + ChartData(5, 4_500_000, false), + ChartData(6, 3_900_000, false), + ChartData(7, 5_200_000, false), + ChartData(8, 4_800_000, false), + ChartData(9, 4_300_000, false), + ChartData(10, 3_700_000, false), + ChartData(11, 4_000_000, false), + ChartData(12, 5_500_000, false) + ) + ), + onIntent = { } + ) } -} \ No newline at end of file +} + diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt b/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt index 3f85cdb..6c7e67d 100644 --- a/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt +++ b/feature/sales/src/main/java/com/example/sales/type/SalesEffect.kt @@ -1,6 +1,18 @@ package com.example.sales.type +// feature/sales/type/SalesEffect.kt + +/** + * Sales 화면의 부수 효과 (Effect) + */ sealed class SalesEffect { - data class ShowToast(val message: String) : SalesEffect() - data class ExportSalesReport(val data: List) : SalesEffect() + /** + * 에러 메시지 표시 + */ + data class ShowError(val message: String) : SalesEffect() + + /** + * 성공 메시지 표시 + */ + data class ShowSuccess(val message: String) : SalesEffect() } \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt b/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt index a9de999..4e5d0f6 100644 --- a/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt +++ b/feature/sales/src/main/java/com/example/sales/type/SalesIntent.kt @@ -1,14 +1,30 @@ package com.example.sales.type -import java.time.LocalDate + +// feature/sales/type/SalesIntent.kt + +/** + * Sales 화면의 사용자 의도 (Intent) + */ sealed class SalesIntent { + /** + * 매출 데이터 로딩 + */ object LoadSalesData : SalesIntent() - data class SelectPeriod(val period: SalesPeriod) : SalesIntent() + + /** + * 데이터 새로고침 + */ object RefreshData : SalesIntent() - object ClearError : SalesIntent() -} -enum class SalesPeriod { - TODAY, WEEK, MONTH, YEAR + /** + * 월 선택 + */ + data class SelectMonth(val month: Int) : SalesIntent() + + /** + * 연도 선택 + */ + data class SelectYear(val year: Int) : SalesIntent() } \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/type/SalesState.kt b/feature/sales/src/main/java/com/example/sales/type/SalesState.kt index b98b54d..f644ed9 100644 --- a/feature/sales/src/main/java/com/example/sales/type/SalesState.kt +++ b/feature/sales/src/main/java/com/example/sales/type/SalesState.kt @@ -1,24 +1,28 @@ package com.example.sales.type +import com.example.domain.model.ChartData +import com.example.domain.model.SalesData +import com.example.domain.model.SalesSummary +import com.example.domain.model.TotalSales import java.time.LocalDate +/** + * Sales 화면의 상태 (State) + */ data class SalesState( - val salesData: List = emptyList(), - val selectedPeriod: SalesPeriod = SalesPeriod.TODAY, val isLoading: Boolean = false, + val totalSales: TotalSales? = null, + val yearlySales: List = emptyList(), + val monthlySales: List = emptyList(), + val salesSummary: SalesSummary? = null, + val chartData: List = emptyList(), + val selectedMonth: Int? = null, + val selectedYear: Int = 2025, val error: String? = null ) { - val totalSales: Long - get() = salesData.sumOf { it.amount } - - val totalOrders: Int - get() = salesData.sumOf { it.orderCount } - - val averageOrderValue: Long - get() = if (totalOrders > 0) totalSales / totalOrders else 0 -} - -data class SalesData( - val amount: Long, - val orderCount: Int -) \ No newline at end of file + /** + * 빈 상태 여부 + */ + val isEmpty: Boolean + get() = totalSales == null && yearlySales.isEmpty() && monthlySales.isEmpty() +} \ No newline at end of file diff --git a/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt b/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt index df9029b..ad1424e 100644 --- a/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt +++ b/feature/sales/src/main/java/com/example/sales/viewmodel/SalesViewModel.kt @@ -1,74 +1,183 @@ package com.example.sales.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.sales.type.SalesData +import com.example.domain.model.ChartData +import com.example.domain.model.SalesSummary +import com.example.domain.usecase.sale.GetMonthlySalesUseCase +import com.example.domain.usecase.sale.GetTotalSalesUseCase +import com.example.domain.usecase.sale.GetYearlySalesUseCase import com.example.sales.type.SalesEffect import com.example.sales.type.SalesIntent -import com.example.sales.type.SalesPeriod import com.example.sales.type.SalesState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.time.LocalDate +import javax.inject.Inject -class SalesViewModel : ViewModel() { +/** + * 매출 관리 ViewModel + * MVI 패턴으로 구현 + */ +@HiltViewModel +class SalesViewModel @Inject constructor( + private val getTotalSalesUseCase: GetTotalSalesUseCase, + private val getYearlySalesUseCase: GetYearlySalesUseCase, + private val getMonthlySalesUseCase: GetMonthlySalesUseCase +) : ViewModel() { + + companion object { + private const val TAG = "SalesViewModel" + } + + // State private val _state = MutableStateFlow(SalesState()) val state: StateFlow = _state.asStateFlow() - private val _effect = MutableSharedFlow() - val effect: SharedFlow = _effect.asSharedFlow() + // Effects + private val _effect = Channel() + val effect = _effect.receiveAsFlow() init { + Log.d(TAG, "🚀 SalesViewModel 초기화") handleIntent(SalesIntent.LoadSalesData) } + /** + * Intent 처리 + */ fun handleIntent(intent: SalesIntent) { + Log.d(TAG, "📩 Intent 수신: $intent") + when (intent) { is SalesIntent.LoadSalesData -> loadSalesData() - is SalesIntent.SelectPeriod -> selectPeriod(intent.period) is SalesIntent.RefreshData -> refreshData() - is SalesIntent.ClearError -> clearError() + is SalesIntent.SelectMonth -> selectMonth(intent.month) + is SalesIntent.SelectYear -> selectYear(intent.year) } } + /** + * 매출 데이터 로딩 + */ private fun loadSalesData() { viewModelScope.launch { + Log.d(TAG, "🔄 매출 데이터 로딩 시작") _state.value = _state.value.copy(isLoading = true, error = null) try { - // 임시 데이터 - val salesData = getSampleSalesData() + // 병렬로 모든 데이터 조회 + val totalSalesResult = getTotalSalesUseCase() + val yearlySalesResult = getYearlySalesUseCase() + val monthlySalesResult = getMonthlySalesUseCase() + + // 결과 처리 + var hasError = false + var errorMessage = "" + + if (totalSalesResult.isFailure) { + hasError = true + errorMessage = "총 매출 조회 실패" + Log.e(TAG, "❌ 총 매출 조회 실패", totalSalesResult.exceptionOrNull()) + } + + if (yearlySalesResult.isFailure) { + hasError = true + errorMessage = "연도별 매출 조회 실패" + Log.e(TAG, "❌ 연도별 매출 조회 실패", yearlySalesResult.exceptionOrNull()) + } + + if (monthlySalesResult.isFailure) { + hasError = true + errorMessage = "월별 매출 조회 실패" + Log.e(TAG, "❌ 월별 매출 조회 실패", monthlySalesResult.exceptionOrNull()) + } + + if (hasError) { + _state.value = _state.value.copy(isLoading = false, error = errorMessage) + _effect.send(SalesEffect.ShowError(errorMessage)) + return@launch + } + + // 성공 시 데이터 업데이트 + val totalSales = totalSalesResult.getOrNull() + val yearlySales = yearlySalesResult.getOrNull() ?: emptyList() + val monthlySales = monthlySalesResult.getOrNull() ?: emptyList() + + Log.d(TAG, "✅ 매출 데이터 로딩 성공") + Log.d(TAG, "📊 총 매출: ${totalSales?.totalSales}") + Log.d(TAG, "📊 연도별 매출: ${yearlySales.size}개") + Log.d(TAG, "📊 월별 매출: ${monthlySales.size}개") + + // 요약 정보 계산 + val summary = SalesSummary.fromMonthlySales(monthlySales) + Log.d(TAG, "📊 요약 정보 계산 완료: $summary") + + // 차트 데이터 생성 + val chartData = ChartData.fromMonthlySales(monthlySales) + Log.d(TAG, "📊 차트 데이터 생성 완료: ${chartData.size}개") + _state.value = _state.value.copy( isLoading = false, - salesData = salesData + totalSales = totalSales, + yearlySales = yearlySales, + monthlySales = monthlySales, + salesSummary = summary, + chartData = chartData, + error = null ) + } catch (e: Exception) { + Log.e(TAG, "❌ 매출 데이터 로딩 실패", e) _state.value = _state.value.copy( isLoading = false, - error = "매출 데이터를 불러올 수 없습니다" + error = e.message ?: "알 수 없는 오류" ) + _effect.send(SalesEffect.ShowError("매출 데이터를 불러오는데 실패했습니다.")) } } } - private fun selectPeriod(period: SalesPeriod) { - _state.value = _state.value.copy(selectedPeriod = period) + /** + * 데이터 새로고침 + */ + private fun refreshData() { + Log.d(TAG, "🔄 데이터 새로고침") loadSalesData() } - private fun refreshData() = loadSalesData() + /** + * 월 선택 + */ + private fun selectMonth(month: Int) { + Log.d(TAG, "📅 월 선택: ${month}월") - private fun clearError() { - _state.value = _state.value.copy(error = null) - } + // 차트 데이터 업데이트 (선택된 월 하이라이트) + val updatedChartData = ChartData.fromMonthlySales( + _state.value.monthlySales, + highlightMonth = month + ) - private fun getSampleSalesData(): List { - return listOf( - SalesData(amount = 150000, orderCount = 25), - SalesData(amount = 200000, orderCount = 30), - SalesData(amount = 180000, orderCount = 28) + _state.value = _state.value.copy( + selectedMonth = month, + chartData = updatedChartData ) + + Log.d(TAG, "✅ ${month}월 선택 완료") } -} + /** + * 연도 선택 + */ + private fun selectYear(year: Int) { + Log.d(TAG, "📅 연도 선택: ${year}년") + _state.value = _state.value.copy(selectedYear = year) + + // TODO: 연도별 데이터 필터링 또는 새로운 API 호출 + Log.d(TAG, "✅ ${year}년 선택 완료") + } +} \ No newline at end of file diff --git a/feature/staff/build.gradle.kts b/feature/staff/build.gradle.kts index e7f01e1..4f234e8 100644 --- a/feature/staff/build.gradle.kts +++ b/feature/staff/build.gradle.kts @@ -22,8 +22,8 @@ android { // 이 부분 추가 ⬇️ compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } // Lint 설정 추가 ⬇️ lint { @@ -31,7 +31,7 @@ android { abortOnError = false } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } buildFeatures { @@ -57,11 +57,16 @@ android { dependencies { // 코어 UI 모듈 의존성 implementation(project(":core:ui")) + implementation(project(":domain")) + implementation(project(":core:common")) // Hilt 관련 추가 (커스텀 플러그인이 제공 안할 경우) implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + // 이미지 처리 및 권한 관련 + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.core:core-ktx:1.12.0") // Compose 기본 의존성 implementation("androidx.compose.ui:ui") @@ -80,4 +85,8 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + + // coil + implementation("io.coil-kt:coil-compose:2.5.0") + } diff --git a/feature/staff/src/main/java/com/example/staff/component/DropdownSelector.kt b/feature/staff/src/main/java/com/example/staff/component/DropdownSelector.kt new file mode 100644 index 0000000..ea84416 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/DropdownSelector.kt @@ -0,0 +1,48 @@ +package com.example.staff.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DropdownSelector( + label: String, + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Column { + Text(label, style = MaterialTheme.typography.labelSmall) + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = selectedOption.toString(), + onValueChange = {}, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = true }, + enabled = false + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth() + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.toString()) }, + onClick = { + onOptionSelected(option) + expanded = false + } + ) + } + } + } + } +} diff --git a/feature/staff/src/main/java/com/example/staff/component/StaffBankSelector.kt b/feature/staff/src/main/java/com/example/staff/component/StaffBankSelector.kt new file mode 100644 index 0000000..401b8fd --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/StaffBankSelector.kt @@ -0,0 +1,122 @@ +// :feature:staff/src/main/java/com/example/staff/component/StaffBankSelector.kt +package com.example.staff.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.domain.model.Bank +import com.example.ui.components.textfields.BarrionTextField +import com.example.ui.components.textfields.BarrionTextFieldState +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors + +/** + * 직원 은행 선택 드롭다운 + * Core UI의 BarrionTextField를 활용한 드롭다운 컴포넌트 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffBankSelector( + selectedBank: Bank, + onBankSelected: (Bank) -> Unit, + modifier: Modifier = Modifier, + label: String = "은행", + isError: Boolean = false, + errorText: String? = null, + enabled: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + + // 에러 상태에 따른 필드 상태 결정 + val fieldState = when { + isError -> BarrionTextFieldState.ERROR + !enabled -> BarrionTextFieldState.DISABLED + else -> BarrionTextFieldState.FOCUSED + } + + Column(modifier = modifier.fillMaxWidth()) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded } + ) { + BarrionTextField( + value = selectedBank.displayName, + onValueChange = {}, // 읽기 전용 + title = label, + placeholder = "은행을 선택하세요", + fieldState = fieldState, + enabled = enabled, + readOnly = true, + modifier = Modifier + .menuAnchor() + .clickable(enabled = enabled) { + if (enabled) expanded = true + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + // 은행 목록을 정렬된 순서로 표시 + Bank.entries.sortedBy { it.sortOrder }.forEach { bank -> + DropdownMenuItem( + text = { + Text( + text = bank.displayName, + style = MaterialTheme.typography.bodyMedium, + color = if (bank == selectedBank) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + }, + onClick = { + onBankSelected(bank) + expanded = false + }, + colors = MenuDefaults.itemColors( + textColor = if (bank == selectedBank) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + ) + } + } + } + + // 에러 메시지 표시 + if (isError && errorText != null) { + Text( + text = errorText, + color = MaterialTheme.barrionColors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, top = 4.dp) + ) + } + } +} + +/** + * 은행 목록을 사용자 친화적인 순서로 정렬하기 위한 확장 속성 + * 주요 은행들을 먼저 배치하고 나머지는 가나다순 + */ +private val Bank.sortOrder: Int + get() = when (this) { + Bank.KB -> 0 // 국민 + Bank.SHINHAN -> 1 // 신한 + Bank.HANA -> 2 // 하나 + Bank.WOORI -> 3 // 우리 + Bank.NH -> 4 // 농협 + Bank.IBK -> 5 // 기업 + Bank.BUSAN -> 6 // 부산 + Bank.DAEGU -> 7 // 대구 + Bank.KWANGJU -> 8 // 광주 + Bank.JEONBUK -> 9 // 전북 + } \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/component/StaffDetailItem.kt b/feature/staff/src/main/java/com/example/staff/component/StaffDetailItem.kt new file mode 100644 index 0000000..a3ef87f --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/StaffDetailItem.kt @@ -0,0 +1,174 @@ +// :feature:staff/src/main/java/com/example/staff/component/StaffDetailItem.kt +package com.example.staff.component + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors +import java.text.NumberFormat +import java.util.* + +/** + * 직원 상세 정보 아이템 + * 이미지 디자인과 동일한 레이아웃: 아이콘 + 라벨 + 값 + */ +@Composable +fun StaffDetailItem( + label: String, + value: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = Spacing.Small), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.Medium) + ) { + // 아이콘 (선택적) + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.barrionColors.grayMedium, + modifier = Modifier.size(20.dp) + ) + } + + // 라벨과 값을 세로로 배치 + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + // 라벨 (작은 회색 텍스트) + Text( + text = label, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.barrionColors.grayMedium, + fontWeight = FontWeight.Normal + ) + ) + + // 값 (큰 검은 텍스트) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.barrionColors.grayBlack, + fontWeight = FontWeight.Medium + ) + ) + } + } +} + +/** + * 아이콘이 포함된 직원 상세 정보 아이템들 + * 각 정보 타입에 맞는 아이콘을 자동으로 설정 + */ +@Composable +fun StaffDetailItemWithIcon( + label: String, + value: String, + type: StaffDetailType, + modifier: Modifier = Modifier +) { + val icon = when (type) { + StaffDetailType.PHONE -> Icons.Outlined.Phone + StaffDetailType.SALARY -> Icons.Outlined.Schedule + StaffDetailType.POSITION -> Icons.Outlined.Work + StaffDetailType.ACCOUNT -> Icons.Outlined.AccountBalance + StaffDetailType.GENERAL -> null + } + + StaffDetailItem( + label = label, + value = value, + icon = icon, + modifier = modifier + ) +} + +/** + * 직원 상세 정보 타입 정의 + */ +enum class StaffDetailType { + PHONE, // 전화번호 + SALARY, // 시급 + POSITION, // 직무 + ACCOUNT, // 계좌번호 + GENERAL // 일반 정보 +} + +/** + * 시급을 원화 형식으로 포맷팅 (12,500원) + */ +fun formatHourlyWage(wage: Int): String { + val formatter = NumberFormat.getNumberInstance(Locale.KOREA) + return "${formatter.format(wage)}원" +} + +/** + * 전화번호를 010-xxxx-xxxx 형식으로 포맷팅 + * 함수명을 formatDetailPhoneNumber로 변경하여 중복 방지 + */ +fun formatDetailPhoneNumber(phoneNumber: String): String { + return when { + phoneNumber.length == 11 && phoneNumber.startsWith("010") -> { + "${phoneNumber.substring(0, 3)}-${phoneNumber.substring(3, 7)}-${phoneNumber.substring(7)}" + } + phoneNumber.contains("-") -> phoneNumber + else -> phoneNumber + } +} + +/** + * 편의 함수들 - 자주 사용되는 직원 정보 표시 + */ +@Composable +fun StaffPhoneItem(phoneNumber: String, modifier: Modifier = Modifier) { + StaffDetailItemWithIcon( + label = "전화번호", + value = formatDetailPhoneNumber(phoneNumber), + type = StaffDetailType.PHONE, + modifier = modifier + ) +} + +@Composable +fun StaffSalaryItem(hourlyWage: Int, modifier: Modifier = Modifier) { + StaffDetailItemWithIcon( + label = "시급", + value = formatHourlyWage(hourlyWage), + type = StaffDetailType.SALARY, + modifier = modifier + ) +} + +@Composable +fun StaffPositionItem(position: String, modifier: Modifier = Modifier) { + StaffDetailItemWithIcon( + label = "직무", + value = position, + type = StaffDetailType.POSITION, + modifier = modifier + ) +} + +@Composable +fun StaffAccountItem(bankName: String, accountNumber: String, modifier: Modifier = Modifier) { + StaffDetailItemWithIcon( + label = "계좌번호", + value = "$bankName $accountNumber", + type = StaffDetailType.ACCOUNT, + modifier = modifier + ) +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/component/StaffListItem.kt b/feature/staff/src/main/java/com/example/staff/component/StaffListItem.kt new file mode 100644 index 0000000..8a81c8c --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/StaffListItem.kt @@ -0,0 +1,118 @@ +// :feature:staff/src/main/java/com/example/staff/component/StaffListItem.kt +package com.example.staff.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.domain.model.Staff +import com.example.ui.theme.CornerRadius +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors + +/** + * 직원 목록 아이템 카드 + * 이미지 디자인과 동일한 레이아웃: 아바타 + 이름/전화번호 + 직무 태그 + */ +@Composable +fun StaffListItem( + staff: Staff, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(CornerRadius.Large), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp, + pressedElevation = 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.Medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.Medium) + ) { + // 아바타 (이름 첫 글자) + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.barrionColors.blueVeryPale), + contentAlignment = Alignment.Center + ) { + Text( + text = staff.name.firstOrNull()?.toString() ?: "?", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.barrionColors.primaryBlue + ) + } + + // 직원 정보 (이름, 전화번호) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = staff.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.barrionColors.grayBlack + ) + Text( + text = formatStaffPhoneNumber(staff.phoneNumber), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } + + // 직무 태그 (이미지와 동일한 스타일) + Surface( + shape = RoundedCornerShape(CornerRadius.Medium), + color = MaterialTheme.barrionColors.blueWhite, + modifier = Modifier.wrapContentSize() + ) { + Text( + text = staff.position.displayName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.barrionColors.primaryBlue, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 6.dp + ) + ) + } + } + } +} + +/** + * 전화번호를 010-xxxx-xxxx 형식으로 포맷팅 + * 함수명을 formatStaffPhoneNumber로 변경하여 중복 방지 + */ +private fun formatStaffPhoneNumber(phoneNumber: String): String { + return when { + phoneNumber.length == 11 && phoneNumber.startsWith("010") -> { + "${phoneNumber.substring(0, 3)}-${phoneNumber.substring(3, 7)}-${phoneNumber.substring(7)}" + } + phoneNumber.contains("-") -> phoneNumber // 이미 포맷된 경우 + else -> phoneNumber // 기타 경우 원본 반환 + } +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/component/StaffPositionSelector.kt b/feature/staff/src/main/java/com/example/staff/component/StaffPositionSelector.kt new file mode 100644 index 0000000..93853a8 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/StaffPositionSelector.kt @@ -0,0 +1,115 @@ +// :feature:staff/src/main/java/com/example/staff/component/StaffPositionSelector.kt +package com.example.staff.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.domain.model.Position +import com.example.ui.components.textfields.BarrionTextField +import com.example.ui.components.textfields.BarrionTextFieldState +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors + +/** + * 직원 직무 선택 드롭다운 + * Core UI의 BarrionTextField를 활용한 드롭다운 컴포넌트 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffPositionSelector( + selectedPosition: Position, + onPositionSelected: (Position) -> Unit, + modifier: Modifier = Modifier, + label: String = "직무", + isError: Boolean = false, + errorText: String? = null, + enabled: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + + // 에러 상태에 따른 필드 상태 결정 + val fieldState = when { + isError -> BarrionTextFieldState.ERROR + !enabled -> BarrionTextFieldState.DISABLED + else -> BarrionTextFieldState.FOCUSED + } + + Column(modifier = modifier.fillMaxWidth()) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded } + ) { + BarrionTextField( + value = selectedPosition.displayName, + onValueChange = {}, // 읽기 전용 + title = label, + placeholder = "직무를 선택하세요", + fieldState = fieldState, + enabled = enabled, + readOnly = true, + modifier = Modifier + .menuAnchor() + .clickable(enabled = enabled) { + if (enabled) expanded = true + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Position.entries.forEach { position -> + DropdownMenuItem( + text = { + Text( + text = position.displayName, + style = MaterialTheme.typography.bodyMedium, + color = if (position == selectedPosition) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + }, + onClick = { + onPositionSelected(position) + expanded = false + }, + colors = MenuDefaults.itemColors( + textColor = if (position == selectedPosition) { + MaterialTheme.barrionColors.primaryBlue + } else { + MaterialTheme.barrionColors.grayBlack + } + ) + ) + } + } + } + + // 에러 메시지 표시 + if (isError && errorText != null) { + Text( + text = errorText, + color = MaterialTheme.barrionColors.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 4.dp, top = 4.dp) + ) + } + } +} + +/** + * 선택 가능한 직무 목록을 미리 정의된 순서대로 반환 + */ +private val Position.sortOrder: Int + get() = when (this) { + Position.MANAGER -> 0 + Position.BARISTA -> 1 + Position.CASHIER -> 2 + Position.KITCHEN -> 3 + Position.KIOSK_MANAGER -> 4 + } \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/component/StaffTextField.kt b/feature/staff/src/main/java/com/example/staff/component/StaffTextField.kt new file mode 100644 index 0000000..730eb33 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/component/StaffTextField.kt @@ -0,0 +1,51 @@ +// :feature:staff/src/main/java/com/example/staff/component/StaffTextField.kt +package com.example.staff.component + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import com.example.ui.components.textfields.BarrionTextField +import com.example.ui.components.textfields.BarrionTextFieldState + +/** + * 직원 폼용 텍스트 필드 + * Core UI의 BarrionTextField를 활용한 Staff 전용 컴포넌트 + */ +@Composable +fun StaffTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + isError: Boolean = false, + errorText: String? = null, + keyboardType: KeyboardType = KeyboardType.Text, + enabled: Boolean = true, + readOnly: Boolean = false +) { + // 에러 상태에 따른 필드 상태 결정 + val fieldState = when { + isError -> BarrionTextFieldState.ERROR + value.isNotEmpty() -> BarrionTextFieldState.FOCUSED + !enabled -> BarrionTextFieldState.DISABLED + else -> BarrionTextFieldState.NORMAL + } + + BarrionTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + title = label, + placeholder = placeholder.ifEmpty { "${label}을(를) 입력하세요" }, + fieldState = fieldState, + enabled = enabled, + readOnly = readOnly, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + singleLine = true + ) + + // 에러 메시지 표시는 BarrionTextField 내부에서 처리되므로 + // 별도로 추가하지 않음 (필요시 BarrionValidatedTextField 사용) +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/screen/StaffDetailScreen.kt b/feature/staff/src/main/java/com/example/staff/screen/StaffDetailScreen.kt new file mode 100644 index 0000000..de7bce2 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/screen/StaffDetailScreen.kt @@ -0,0 +1,230 @@ +// :feature:staff/src/main/java/com/example/staff/screen/StaffDetailScreen.kt +package com.example.staff.screen + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.domain.model.Staff +import com.example.staff.component.* +import com.example.staff.type.StaffEffect +import com.example.staff.type.StaffIntent +import com.example.staff.viewmodel.StaffViewModel +import com.example.ui.theme.barrionColors +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffDetailScreen( + viewModel: StaffViewModel = hiltViewModel(), + staffId: Long, + onBack: () -> Unit // ✅ onEditClick 파라미터 제거 +) { + val state = viewModel.state + val context = LocalContext.current + + LaunchedEffect(staffId) { + viewModel.onIntent(StaffIntent.SelectStaff(staffId)) + } + + LaunchedEffect(Unit) { + viewModel.effect.collectLatest { effect -> + when (effect) { + is StaffEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + StaffEffect.NavigateBack -> onBack() + else -> {} + } + } + } + + val staff = state.selectedStaff + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "직원 정보", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.barrionColors.grayBlack + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.onIntent(StaffIntent.NavigateBack) }) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + tint = MaterialTheme.barrionColors.grayBlack + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.barrionColors.white + ) + ) + }, + containerColor = MaterialTheme.barrionColors.grayWhite + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + staff?.let { staffInfo -> + // 프로필 카드 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.barrionColors.blueVeryPale), + contentAlignment = Alignment.Center + ) { + Text( + text = staffInfo.name.firstOrNull()?.toString() ?: "?", + color = MaterialTheme.barrionColors.primaryBlue, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineMedium + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = staffInfo.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = staffInfo.position.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } + } + + // 정보 카드 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.barrionColors.white + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // 전화번호 + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("전화번호", modifier = Modifier.width(80.dp)) + Text(formatDetailPhoneNumber(staffInfo.phoneNumber)) + } + + Divider(color = MaterialTheme.barrionColors.grayVeryLight) + + // 시급 + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("시급", modifier = Modifier.width(80.dp)) + Text(formatHourlyWage(staffInfo.hourlyWage)) + } + + Divider(color = MaterialTheme.barrionColors.grayVeryLight) + + // 직무 + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("직무", modifier = Modifier.width(80.dp)) + Text(staffInfo.position.displayName) + } + + Divider(color = MaterialTheme.barrionColors.grayVeryLight) + + // 계좌 + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("계좌", modifier = Modifier.width(80.dp)) + Text("${staffInfo.bank.displayName} ${staffInfo.accountNumber}") + } + } + } + + // ✅ 삭제하기 버튼만 있음 (수정하기 버튼 제거) + Button( + onClick = { + viewModel.onIntent(StaffIntent.DeleteStaff(staffInfo.id)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = ButtonDefaults.buttonColors( + //containerColor = MaterialTheme.barrionColors.errorRed // ✅ 삭제 색상으로 변경 + ) + ) { + Text("삭제하기", color = MaterialTheme.barrionColors.white) + } + + // 하단 여백 + Spacer(modifier = Modifier.height(150.dp)) + + } ?: run { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (state.isLoading) { + CircularProgressIndicator(color = MaterialTheme.barrionColors.primaryBlue) + } else { + Text("직원 정보를 불러올 수 없습니다.") + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/screen/StaffEditScreen.kt b/feature/staff/src/main/java/com/example/staff/screen/StaffEditScreen.kt new file mode 100644 index 0000000..3261ee3 --- /dev/null +++ b/feature/staff/src/main/java/com/example/staff/screen/StaffEditScreen.kt @@ -0,0 +1,244 @@ +// :feature:staff/src/main/java/com/example/staff/screen/StaffEditScreen.kt +package com.example.staff.screen + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.domain.model.Bank +import com.example.domain.model.Position +import com.example.domain.model.Staff +import com.example.staff.component.* +import com.example.staff.type.StaffEffect +import com.example.staff.type.StaffIntent +import com.example.staff.viewmodel.StaffViewModel +import com.example.ui.components.buttons.BarrionFullButton +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffEditScreen( + viewModel: StaffViewModel = hiltViewModel(), + staffId: Long? = null, + onBack: () -> Unit +) { + val context = LocalContext.current + val state = viewModel.state + val staff = state.selectedStaff + val isEditMode = staffId != null + + // 최초 로딩 시 직원 정보 불러오기 (수정 모드일 때) + LaunchedEffect(staffId) { + staffId?.let { + viewModel.onIntent(StaffIntent.SelectStaff(it)) + } + } + + // 효과 처리 (토스트 및 뒤로가기) + LaunchedEffect(Unit) { + viewModel.effect.collectLatest { effect -> + when (effect) { + is StaffEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + StaffEffect.NavigateBack -> onBack() + else -> {} + } + } + } + + // 입력 상태 (6개 필수 필드) + var name by remember { mutableStateOf("") } + var phoneNumber by remember { mutableStateOf("") } + var hourlyWage by remember { mutableStateOf("") } + var accountNumber by remember { mutableStateOf("") } + var selectedPosition by remember { mutableStateOf(null) } + var selectedBank by remember { mutableStateOf(null) } + + // 직원 정보 반영 (수정 모드일 때) + LaunchedEffect(staff) { + staff?.let { + name = it.name + phoneNumber = it.phoneNumber + hourlyWage = it.hourlyWage.toString() + accountNumber = it.accountNumber + selectedPosition = it.position + selectedBank = it.bank + } + } + + // 초기값 설정 (추가 모드일 때) + LaunchedEffect(Unit) { + if (!isEditMode && selectedPosition == null) { + selectedPosition = Position.BARISTA // 기본값 + selectedBank = Bank.WOORI // 기본값 + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + // 제목을 뒤로가기 버튼 바로 옆에 붙임 (이미지 1처럼) + Text( + text = if (isEditMode) "직원 정보 수정" else "직원 등록", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.barrionColors.grayBlack + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.onIntent(StaffIntent.NavigateBack) }) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + tint = MaterialTheme.barrionColors.grayBlack + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack + ) + ) + }, + containerColor = MaterialTheme.barrionColors.white // 배경을 흰색으로 변경 + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(Spacing.Medium), + verticalArrangement = Arrangement.spacedBy(Spacing.Medium) + ) { + // 6개 필수 필드 (이미지 3과 동일한 순서) + + // 1. 이름 + StaffTextField( + label = "이름", + value = name, + onValueChange = { name = it }, + placeholder = "이름을 입력하세요" + ) + + // 2. 전화번호 + StaffTextField( + label = "전화번호", + value = phoneNumber, + onValueChange = { input -> + // 자동 하이픈 추가 포맷팅 + val digitsOnly = input.replace("-", "") + phoneNumber = when { + digitsOnly.length <= 3 -> digitsOnly + digitsOnly.length <= 7 -> "${digitsOnly.substring(0, 3)}-${digitsOnly.substring(3)}" + digitsOnly.length <= 11 -> "${digitsOnly.substring(0, 3)}-${digitsOnly.substring(3, 7)}-${digitsOnly.substring(7)}" + else -> phoneNumber // 현재 값 유지 + } + }, + placeholder = "010-0000-0000", + keyboardType = KeyboardType.Phone + ) + + // 3. 시급 + StaffTextField( + label = "시급", + value = hourlyWage, + onValueChange = { input -> + // 숫자만 입력 허용 + if (input.isEmpty() || input.all { it.isDigit() }) { + hourlyWage = input + } + }, + placeholder = "시급을 입력하세요", + keyboardType = KeyboardType.Number + ) + + // 4. 직무 드롭다운 (이미지 3과 동일) + selectedPosition?.let { position -> + StaffPositionSelector( + selectedPosition = position, + onPositionSelected = { selectedPosition = it }, + label = "직무" + ) + } + + // 5. 은행 드롭다운 (이미지 3과 동일) + selectedBank?.let { bank -> + StaffBankSelector( + selectedBank = bank, + onBankSelected = { selectedBank = it }, + label = "은행" + ) + } + + // 6. 계좌번호 + StaffTextField( + label = "계좌번호", + value = accountNumber, + onValueChange = { input -> + // 숫자와 하이픈만 허용 + if (input.isEmpty() || input.all { it.isDigit() || it == '-' }) { + accountNumber = input + } + }, + placeholder = "계좌번호를 입력하세요", + keyboardType = KeyboardType.Number + ) + + // 하단 여백 추가 + Spacer(modifier = Modifier.height(100.dp)) + } + + // 저장 버튼을 하단에 고정 (이미지와 동일한 위치) + BarrionFullButton( + text = if (isEditMode) "변경사항 저장하기" else "직원 등록하기", + onClick = { + val wage = hourlyWage.toIntOrNull() ?: 0 + val newStaff = Staff( + id = staff?.id ?: 0L, + name = name.trim(), + phoneNumber = phoneNumber.replace("-", ""), + hourlyWage = wage, + accountNumber = accountNumber.trim(), + bank = selectedBank!!, + position = selectedPosition!! + ) + + if (isEditMode) { + viewModel.onIntent(StaffIntent.UpdateStaff(newStaff)) + } else { + viewModel.onIntent(StaffIntent.AddStaff(newStaff)) + } + }, + enabled = selectedPosition != null && + selectedBank != null && + name.isNotBlank() && + phoneNumber.isNotBlank() && + hourlyWage.isNotBlank() && + accountNumber.isNotBlank() && + !state.isLoading, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(Spacing.Medium) + ) + } + } +} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt b/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt index 8d6b088..809e3d2 100644 --- a/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt +++ b/feature/staff/src/main/java/com/example/staff/screen/StaffScreen.kt @@ -1,73 +1,181 @@ +// :feature:staff/src/main/java/com/example/staff/screen/StaffScreen.kt package com.example.staff.screen +import android.widget.Toast import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.staff.component.StaffListItem import com.example.staff.type.StaffEffect +import com.example.staff.type.StaffIntent import com.example.staff.viewmodel.StaffViewModel +import com.example.ui.theme.CornerRadius +import com.example.ui.theme.Spacing +import com.example.ui.theme.barrionColors +import kotlinx.coroutines.flow.collectLatest +@OptIn(ExperimentalMaterial3Api::class) @Composable fun StaffScreen( - viewModel: StaffViewModel = viewModel() + viewModel: StaffViewModel = hiltViewModel(), + onNavigateToDetail: (Long) -> Unit, // ✅ 직원 정보 페이지로 이동 + onNavigateToAdd: () -> Unit + // ✅ onNavigateToEdit 파라미터 제거 (더 이상 사용 안함) ) { - val state by viewModel.state.collectAsState() + val state = viewModel.state + val context = LocalContext.current + var searchQuery by remember { mutableStateOf("") } - // Effect 처리 - LaunchedEffect(viewModel.effect) { - viewModel.effect.collect { effect -> + LaunchedEffect(Unit) { + viewModel.onIntent(StaffIntent.LoadStaffList) + } + + LaunchedEffect(Unit) { + viewModel.effect.collectLatest { effect -> when (effect) { - is StaffEffect.ShowToast -> { - // Toast 처리 - } - is StaffEffect.NavigateToStaffDetail -> { - // Navigation 처리 - } - is StaffEffect.NavigateToStaffAdd -> { - // Navigation 처리 - } + is StaffEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + is StaffEffect.NavigateToDetail -> onNavigateToDetail(effect.id) + StaffEffect.NavigateBack -> {} } } } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + // 검색어 변경 시 검색 실행 + LaunchedEffect(searchQuery) { + viewModel.onIntent(StaffIntent.SearchStaff(searchQuery)) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + // 제목을 가운데 정렬 (메뉴 관리와 동일한 방식) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "직원 목록", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + textAlign = TextAlign.Center, + color = MaterialTheme.barrionColors.grayBlack + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.barrionColors.white, + titleContentColor = MaterialTheme.barrionColors.grayBlack + ), + actions = { + IconButton(onClick = onNavigateToAdd) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "직원 추가", + tint = MaterialTheme.barrionColors.primaryBlue + ) + } + } + ) + }, + containerColor = MaterialTheme.barrionColors.grayWhite // 더 밝은 배경 + ) { innerPadding -> Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) ) { - Text( - text = "👤", - style = MaterialTheme.typography.displayLarge - ) - Text( - text = "직원 관리", - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = "직원 등록 및 권한 관리 화면", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + // 검색바 + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("이름 또는 전화번호로 검색") }, + modifier = Modifier + .fillMaxWidth() + .padding(Spacing.Medium), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.barrionColors.white, + focusedContainerColor = MaterialTheme.barrionColors.white, + unfocusedBorderColor = MaterialTheme.barrionColors.grayLight, + focusedBorderColor = MaterialTheme.barrionColors.primaryBlue + ), + singleLine = true ) - Text( - text = "개발 예정", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } -} -@Preview -@Composable -private fun StaffScreenPreview() { - MaterialTheme { - StaffScreen() + // 직원 목록 + when { + state.isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(Spacing.Medium), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.barrionColors.primaryBlue + ) + } + } + + state.staffList.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(Spacing.Medium), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.Small) + ) { + Text( + text = if (searchQuery.isNotEmpty()) "검색 결과가 없습니다." else "등록된 직원이 없습니다.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.barrionColors.grayMedium + ) + if (searchQuery.isEmpty()) { + Text( + text = "우상단 + 버튼을 눌러 직원을 추가해보세요.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.barrionColors.grayMedium + ) + } + } + } + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.Medium), + verticalArrangement = Arrangement.spacedBy(Spacing.Small) + ) { + items(items = state.staffList, key = { it.id }) { staff -> + StaffListItem( + staff = staff, + onClick = { + // ✅ 직원 클릭 시 직원 정보 페이지로 이동 (수정됨) + onNavigateToDetail(staff.id) + } + ) + } + } + } + } + } } } \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt b/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt index 1c1df7f..ad02f94 100644 --- a/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt +++ b/feature/staff/src/main/java/com/example/staff/type/StaffEffect.kt @@ -1,7 +1,8 @@ package com.example.staff.type -sealed class StaffEffect { - data class ShowToast(val message: String) : StaffEffect() - data class NavigateToStaffDetail(val staffId: String) : StaffEffect() - object NavigateToStaffAdd : StaffEffect() -} \ No newline at end of file +sealed interface StaffEffect { + data class ShowToast(val message: String) : StaffEffect + data class NavigateToDetail(val id: Long) : StaffEffect + data object NavigateBack : StaffEffect +} + diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt b/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt index 2ffe253..87d4631 100644 --- a/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt +++ b/feature/staff/src/main/java/com/example/staff/type/StaffIntent.kt @@ -1,14 +1,15 @@ package com.example.staff.type +import com.example.domain.model.Staff + + +sealed interface StaffIntent { + data object LoadStaffList : StaffIntent + data class SearchStaff(val query: String) : StaffIntent + data class SelectStaff(val id: Long) : StaffIntent + data class AddStaff(val staff: Staff) : StaffIntent + data class UpdateStaff(val staff: Staff) : StaffIntent + data class DeleteStaff(val id: Long) : StaffIntent + data object NavigateBack : StaffIntent +} -sealed class StaffIntent { - object LoadStaff : StaffIntent() - data class SelectStaff(val staffId: String) : StaffIntent() - data class AddStaff(val staff: Staff) : StaffIntent() - data class UpdateStaff(val staff: Staff) : StaffIntent() - data class DeleteStaff(val staffId: String) : StaffIntent() - data class UpdateStaffRole(val staffId: String, val role: StaffRole) : StaffIntent() - object ShowAddStaffDialog : StaffIntent() - object HideAddStaffDialog : StaffIntent() - object ClearError : StaffIntent() -} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/type/StaffState.kt b/feature/staff/src/main/java/com/example/staff/type/StaffState.kt index 9a1e868..76f708f 100644 --- a/feature/staff/src/main/java/com/example/staff/type/StaffState.kt +++ b/feature/staff/src/main/java/com/example/staff/type/StaffState.kt @@ -1,31 +1,11 @@ package com.example.staff.type +import com.example.domain.model.Staff + data class StaffState( val staffList: List = emptyList(), val selectedStaff: Staff? = null, - val showAddStaffDialog: Boolean = false, val isLoading: Boolean = false, - val error: String? = null -) { - val adminStaff: List - get() = staffList.filter { it.role == StaffRole.ADMIN } - - val managerStaff: List - get() = staffList.filter { it.role == StaffRole.MANAGER } - - val employeeStaff: List - get() = staffList.filter { it.role == StaffRole.EMPLOYEE } -} - -data class Staff( - val id: String, - val name: String, - val email: String, - val phone: String, - val role: StaffRole, - val isActive: Boolean = true + val errorMessage: String? = null, + val successMessage: String? = null ) - -enum class StaffRole { - ADMIN, MANAGER, EMPLOYEE -} \ No newline at end of file diff --git a/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt b/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt index bc523cf..69af850 100644 --- a/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt +++ b/feature/staff/src/main/java/com/example/staff/viewmodel/StaffViewModel.kt @@ -1,124 +1,342 @@ package com.example.staff.viewmodel +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.staff.type.Staff +import com.example.domain.model.Staff +import com.example.domain.usecase.staff.DeleteStaffUseCase +import com.example.domain.usecase.staff.GetStaffByIdUseCase +import com.example.domain.usecase.staff.GetStaffListUseCase +import com.example.domain.usecase.staff.SearchStaffUseCase +import com.example.domain.usecase.staff.UpdateStaffUseCase import com.example.staff.type.StaffEffect import com.example.staff.type.StaffIntent -import com.example.staff.type.StaffRole import com.example.staff.type.StaffState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import javax.inject.Inject +import androidx.compose.runtime.setValue +import com.example.domain.usecase.staff.AddStaffUseCase -class StaffViewModel : ViewModel() { +@HiltViewModel +class StaffViewModel @Inject constructor( + private val getStaffListUseCase: GetStaffListUseCase, + private val searchStaffUseCase: SearchStaffUseCase, + private val getStaffByIdUseCase: GetStaffByIdUseCase, + private val addStaffUseCase: AddStaffUseCase, + private val updateStaffUseCase: UpdateStaffUseCase, + private val deleteStaffUseCase: DeleteStaffUseCase +) : ViewModel() { - private val _state = MutableStateFlow(StaffState()) - val state: StateFlow = _state.asStateFlow() + companion object { + private const val TAG = "StaffViewModel" + } - private val _effect = MutableSharedFlow() - val effect: SharedFlow = _effect.asSharedFlow() + var state by mutableStateOf(StaffState()) + private set - init { - handleIntent(StaffIntent.LoadStaff) - } + private val _effect = Channel() + val effect = _effect.receiveAsFlow() - fun handleIntent(intent: StaffIntent) { + fun onIntent(intent: StaffIntent) { + Log.d(TAG, "📩 Intent 수신: ${intent::class.simpleName}") when (intent) { - is StaffIntent.LoadStaff -> loadStaff() - is StaffIntent.SelectStaff -> selectStaff(intent.staffId) - is StaffIntent.AddStaff -> addStaff(intent.staff) - is StaffIntent.UpdateStaff -> updateStaff(intent.staff) - is StaffIntent.DeleteStaff -> deleteStaff(intent.staffId) - is StaffIntent.UpdateStaffRole -> updateStaffRole(intent.staffId, intent.role) - is StaffIntent.ShowAddStaffDialog -> showAddStaffDialog() - is StaffIntent.HideAddStaffDialog -> hideAddStaffDialog() - is StaffIntent.ClearError -> clearError() + is StaffIntent.LoadStaffList -> { + Log.d(TAG, "🔄 직원 목록 로딩 Intent") + loadStaffList() + } + is StaffIntent.SearchStaff -> { + Log.d(TAG, "🔍 직원 검색 Intent - 검색어: '${intent.query}'") + searchStaff(intent.query) + } + is StaffIntent.SelectStaff -> { + Log.d(TAG, "👤 직원 선택 Intent - ID: ${intent.id}") + selectStaff(intent.id) + } + is StaffIntent.AddStaff -> { + Log.d(TAG, "➕ 직원 추가 Intent - 이름: ${intent.staff.name}") + addStaff(intent.staff) + } + is StaffIntent.UpdateStaff -> { + Log.d(TAG, "✏️ 직원 수정 Intent - 이름: ${intent.staff.name} (ID: ${intent.staff.id})") + updateStaff(intent.staff) + } + is StaffIntent.DeleteStaff -> { + Log.d(TAG, "🗑️ 직원 삭제 Intent - ID: ${intent.id}") + deleteStaff(intent.id) + } + is StaffIntent.NavigateBack -> { + Log.d(TAG, "🔙 뒤로가기 Intent") + sendEffect(StaffEffect.NavigateBack) + } } } - private fun loadStaff() { - viewModelScope.launch { - _state.value = _state.value.copy(isLoading = true, error = null) + private fun loadStaffList() = launch { + Log.d(TAG, "🔄 직원 목록 로딩 시작") + state = state.copy(isLoading = true) - try { - // 임시 데이터 - val staffList = getSampleStaff() - _state.value = _state.value.copy( + try { + val result = getStaffListUseCase() + Log.d(TAG, "📡 UseCase 호출 완료") + + result.onSuccess { staffList -> + Log.d(TAG, "✅ 직원 목록 로딩 성공: ${staffList.size}명") + staffList.forEachIndexed { index, staff -> + Log.d(TAG, "👤 [$index] ${staff.name} (ID: ${staff.id}, ${staff.position.displayName})") + } + + state = state.copy( + staffList = staffList, isLoading = false, - staffList = staffList + errorMessage = null ) - } catch (e: Exception) { - _state.value = _state.value.copy( + Log.d(TAG, "📱 UI 상태 업데이트 완료") + + }.onFailure { exception -> + Log.e(TAG, "❌ 직원 목록 로딩 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "직원 목록을 불러오지 못했습니다.", isLoading = false, - error = "직원 데이터를 불러올 수 없습니다" + staffList = emptyList() ) + sendEffect(StaffEffect.ShowToast("직원 목록을 불러오지 못했습니다.")) } + } catch (e: Exception) { + Log.e(TAG, "💥 직원 목록 로딩 중 예상치 못한 오류: ${e.message}", e) + state = state.copy( + errorMessage = e.message ?: "예상치 못한 오류가 발생했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("예상치 못한 오류가 발생했습니다.")) } } - private fun selectStaff(staffId: String) { - val staff = _state.value.staffList.find { it.id == staffId } - _state.value = _state.value.copy(selectedStaff = staff) + private fun searchStaff(query: String) = launch { + Log.d(TAG, "🔍 직원 검색 시작 - 검색어: '$query'") - viewModelScope.launch { - _effect.emit(StaffEffect.NavigateToStaffDetail(staffId)) - } - } + try { + val result = searchStaffUseCase(query) + Log.d(TAG, "📡 검색 UseCase 호출 완료") - private fun addStaff(staff: Staff) { - _state.value = _state.value.copy( - staffList = _state.value.staffList + staff, - showAddStaffDialog = false - ) + result.onSuccess { searchResults -> + Log.d(TAG, "✅ 검색 성공: ${searchResults.size}명 발견") + searchResults.forEachIndexed { index, staff -> + Log.d(TAG, "🔍 [$index] ${staff.name} (${staff.phoneNumber})") + } - viewModelScope.launch { - _effect.emit(StaffEffect.ShowToast("직원이 추가되었습니다")) + state = state.copy( + staffList = searchResults, + errorMessage = null + ) + + }.onFailure { exception -> + Log.e(TAG, "❌ 검색 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "검색에 실패했습니다." + ) + sendEffect(StaffEffect.ShowToast("검색에 실패했습니다.")) + } + } catch (e: Exception) { + Log.e(TAG, "💥 검색 중 예상치 못한 오류: ${e.message}", e) + state = state.copy(errorMessage = e.message ?: "검색 중 오류가 발생했습니다.") + sendEffect(StaffEffect.ShowToast("검색 중 오류가 발생했습니다.")) } } - private fun updateStaff(staff: Staff) { - _state.value = _state.value.copy( - staffList = _state.value.staffList.map { - if (it.id == staff.id) staff else it + private fun selectStaff(id: Long) = launch { + Log.d(TAG, "👤 직원 선택 시작 - ID: $id") + + try { + val result = getStaffByIdUseCase(id) + Log.d(TAG, "📡 직원 상세 UseCase 호출 완료") + + result.onSuccess { staff -> + Log.d(TAG, "✅ 직원 조회 성공: ${staff.name}") + state = state.copy( + selectedStaff = staff, + errorMessage = null + ) + sendEffect(StaffEffect.NavigateToDetail(id)) + + }.onFailure { exception -> + Log.e(TAG, "❌ 직원 조회 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "직원 정보를 불러오지 못했습니다." + ) + sendEffect(StaffEffect.ShowToast("직원 정보를 불러오지 못했습니다.")) } - ) + } catch (e: Exception) { + Log.e(TAG, "💥 직원 조회 중 예상치 못한 오류: ${e.message}", e) + state = state.copy(errorMessage = e.message ?: "직원 조회 중 오류가 발생했습니다.") + sendEffect(StaffEffect.ShowToast("직원 조회 중 오류가 발생했습니다.")) + } } - private fun deleteStaff(staffId: String) { - _state.value = _state.value.copy( - staffList = _state.value.staffList.filter { it.id != staffId } - ) + private fun addStaff(staff: Staff) = launch { + Log.d(TAG, "➕ 직원 추가 시작 - 이름: ${staff.name}") + Log.d(TAG, "📋 추가할 직원 정보:") + Log.d(TAG, " - 이름: ${staff.name}") + Log.d(TAG, " - 전화번호: ${staff.phoneNumber}") + Log.d(TAG, " - 시급: ${staff.hourlyWage}원") + Log.d(TAG, " - 직무: ${staff.position.displayName}") + Log.d(TAG, " - 은행: ${staff.bank.displayName}") + Log.d(TAG, " - 계좌번호: ${staff.accountNumber}") - viewModelScope.launch { - _effect.emit(StaffEffect.ShowToast("직원이 삭제되었습니다")) + state = state.copy(isLoading = true) + + try { + val result = addStaffUseCase(staff) + Log.d(TAG, "📡 직원 추가 UseCase 호출 완료") + + result.onSuccess { addedStaff -> + Log.d(TAG, "✅ 직원 추가 성공: ${addedStaff.name} (ID: ${addedStaff.id})") + state = state.copy( + successMessage = "직원 추가 완료", + isLoading = false, + errorMessage = null + ) + sendEffect(StaffEffect.ShowToast("${addedStaff.name}님이 추가되었습니다.")) + + Log.d(TAG, "🔄 직원 목록 새로고침 시작") + loadStaffList() + sendEffect(StaffEffect.NavigateBack) + + }.onFailure { exception -> + Log.e(TAG, "❌ 직원 추가 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "직원 추가에 실패했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 추가에 실패했습니다: ${exception.message}")) + } + } catch (e: Exception) { + Log.e(TAG, "💥 직원 추가 중 예상치 못한 오류: ${e.message}", e) + state = state.copy( + errorMessage = e.message ?: "직원 추가 중 오류가 발생했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 추가 중 오류가 발생했습니다.")) } } - private fun updateStaffRole(staffId: String, role: StaffRole) { - _state.value = _state.value.copy( - staffList = _state.value.staffList.map { staff -> - if (staff.id == staffId) { - staff.copy(role = role) - } else { - staff - } + private fun updateStaff(staff: Staff) = launch { + Log.d(TAG, "✏️ 직원 수정 시작 - 이름: ${staff.name} (ID: ${staff.id})") + Log.d(TAG, "📋 수정할 직원 정보:") + Log.d(TAG, " - ID: ${staff.id}") + Log.d(TAG, " - 이름: ${staff.name}") + Log.d(TAG, " - 전화번호: ${staff.phoneNumber}") + Log.d(TAG, " - 시급: ${staff.hourlyWage}원") + Log.d(TAG, " - 직무: ${staff.position.displayName}") + Log.d(TAG, " - 은행: ${staff.bank.displayName}") + Log.d(TAG, " - 계좌번호: ${staff.accountNumber}") + + state = state.copy(isLoading = true) + + try { + val result = updateStaffUseCase(staff) + Log.d(TAG, "📡 직원 수정 UseCase 호출 완료") + + result.onSuccess { updatedStaff -> + Log.d(TAG, "✅ 직원 수정 성공: ${updatedStaff.name}") + state = state.copy( + successMessage = "직원 수정 완료", + isLoading = false, + errorMessage = null + ) + sendEffect(StaffEffect.ShowToast("${updatedStaff.name}님 정보가 수정되었습니다.")) + + Log.d(TAG, "🔄 직원 목록 새로고침 시작") + loadStaffList() + sendEffect(StaffEffect.NavigateBack) + + }.onFailure { exception -> + Log.e(TAG, "❌ 직원 수정 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "직원 수정에 실패했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 수정에 실패했습니다: ${exception.message}")) } - ) + } catch (e: Exception) { + Log.e(TAG, "💥 직원 수정 중 예상치 못한 오류: ${e.message}", e) + state = state.copy( + errorMessage = e.message ?: "직원 수정 중 오류가 발생했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 수정 중 오류가 발생했습니다.")) + } } - private fun showAddStaffDialog() { - _state.value = _state.value.copy(showAddStaffDialog = true) - } + private fun deleteStaff(id: Long) = launch { + Log.d(TAG, "🗑️ 직원 삭제 시작 - ID: $id") - private fun hideAddStaffDialog() { - _state.value = _state.value.copy(showAddStaffDialog = false) + // 삭제할 직원 정보 로그 출력 + val staffToDelete = state.staffList.find { it.id == id } + if (staffToDelete != null) { + Log.d(TAG, "🗑️ 삭제할 직원: ${staffToDelete.name}") + } else { + Log.w(TAG, "⚠️ 삭제할 직원을 목록에서 찾을 수 없음") + } + + state = state.copy(isLoading = true) + + try { + val result = deleteStaffUseCase(id) + Log.d(TAG, "📡 직원 삭제 UseCase 호출 완료") + + result.onSuccess { + Log.d(TAG, "✅ 직원 삭제 성공") + state = state.copy( + successMessage = "직원 삭제 완료", + isLoading = false, + errorMessage = null + ) + sendEffect(StaffEffect.ShowToast("직원이 삭제되었습니다.")) + + Log.d(TAG, "🔄 직원 목록 새로고침 시작") + loadStaffList() + sendEffect(StaffEffect.NavigateBack) + + }.onFailure { exception -> + Log.e(TAG, "❌ 직원 삭제 실패: ${exception.message}", exception) + state = state.copy( + errorMessage = exception.message ?: "직원 삭제에 실패했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 삭제에 실패했습니다: ${exception.message}")) + } + } catch (e: Exception) { + Log.e(TAG, "💥 직원 삭제 중 예상치 못한 오류: ${e.message}", e) + state = state.copy( + errorMessage = e.message ?: "직원 삭제 중 오류가 발생했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("직원 삭제 중 오류가 발생했습니다.")) + } } - private fun clearError() { - _state.value = _state.value.copy(error = null) + private fun sendEffect(effect: StaffEffect) = launch { + Log.d(TAG, "🎭 Effect 전송: ${effect::class.simpleName}") + _effect.send(effect) } - private fun getSampleStaff(): List { - return emptyList() // 일단 빈 리스트 + private fun launch(block: suspend () -> Unit) { + viewModelScope.launch { + try { + block() + } catch (e: Exception) { + Log.e(TAG, "💥 ViewModelScope에서 예상치 못한 오류: ${e.message}", e) + state = state.copy( + errorMessage = e.message ?: "예상치 못한 오류가 발생했습니다.", + isLoading = false + ) + sendEffect(StaffEffect.ShowToast("예상치 못한 오류가 발생했습니다.")) + } + } } } \ No newline at end of file