diff --git a/app/src/main/java/com/autoever/everp/MainActivity.kt b/app/src/main/java/com/autoever/everp/MainActivity.kt index 1599696..7855691 100644 --- a/app/src/main/java/com/autoever/everp/MainActivity.kt +++ b/app/src/main/java/com/autoever/everp/MainActivity.kt @@ -4,50 +4,28 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.lifecycle.lifecycleScope -import com.autoever.everp.domain.repository.PushNotificationRepository -import com.autoever.everp.ui.MainScreen import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import com.autoever.everp.ui.theme.EverpTheme +import com.autoever.everp.ui.MainScreen import com.autoever.everp.ui.navigation.AppNavGraph +import com.autoever.everp.ui.theme.EverpTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var notificationRepository: PushNotificationRepository - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - getFcmToken() setContent { EverpTheme { MainScreen() -// Surface(modifier = Modifier.fillMaxSize()) { -// AppNavGraph() -// } + Surface(modifier = Modifier.fillMaxSize()) { + AppNavGraph() + } } } } - private fun getFcmToken() { - // Repository를 통해서만 FCM 토큰 접근 - // MainActivity에서는 Firebase 객체에 직접 접근하지 않음 - lifecycleScope.launch { - try { - val token = notificationRepository.getToken() - Timber.tag("FCM").i("FCM Token: $token") - // TODO: 서버에 토큰 전송 또는 로컬 저장 등 필요한 작업 수행 - } catch (e: Exception) { - Timber.tag("FCM").e(e, "Fetching FCM token failed") - } - } - } } diff --git a/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt b/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt index 95c0791..4b9bdce 100644 --- a/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt +++ b/app/src/main/java/com/autoever/everp/auth/api/HttpUserApi.kt @@ -16,7 +16,9 @@ import java.net.URL * 기존 GWService.getUserInfo와 동일한 동작을 제공한다. */ class HttpUserApi : UserApi { - private companion object { const val TAG = "UserApi" } + private companion object { + const val TAG = "UserApi" + } override suspend fun getUserInfo(accessToken: String): UserInfo = withContext(Dispatchers.IO) { val url = URL(AuthEndpoint.USER_INFO) @@ -42,6 +44,9 @@ class HttpUserApi : UserApi { // API 응답은 { success, message, data: { ... } } 형태일 수 있으므로 data 객체를 우선 시도 val root = JSONObject(resp) val json = root.optJSONObject("data") ?: root + + Timber.tag(TAG).d("[SUCCESS] 사용자 정보 조회 성공: $json") + UserInfo( userId = json.optString("userId").ifBlank { null }, userName = json.optString("userName").ifBlank { null }, diff --git a/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt b/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt index 73388af..595a15c 100644 --- a/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt +++ b/app/src/main/java/com/autoever/everp/auth/repository/DefaultUserRepository.kt @@ -2,13 +2,16 @@ package com.autoever.everp.auth.repository import com.autoever.everp.auth.api.UserApi import com.autoever.everp.auth.model.UserInfo +import timber.log.Timber import javax.inject.Inject class DefaultUserRepository @Inject constructor( private val api: UserApi, ) : UserRepository { override suspend fun fetchUserInfo(accessToken: String): UserInfo { - return api.getUserInfo(accessToken) + val userInfo = api.getUserInfo(accessToken) + Timber.d("Fetched User Info: $userInfo") + return userInfo } } diff --git a/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt b/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt index e78da3e..7e85dbe 100644 --- a/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt +++ b/app/src/main/java/com/autoever/everp/auth/session/SessionManager.kt @@ -1,56 +1,76 @@ package com.autoever.everp.auth.session -import android.util.Log +import com.autoever.everp.common.annotation.ApplicationScope +import com.autoever.everp.data.datasource.local.AuthLocalDataSource +import com.autoever.everp.domain.model.auth.AccessToken +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import timber.log.Timber +import java.time.LocalDateTime import javax.inject.Inject import javax.inject.Singleton @Singleton class SessionManager @Inject constructor( - private val tokenStore: TokenStore, + private val authLocalDataSource: AuthLocalDataSource, + @ApplicationScope private val applicationScope: CoroutineScope, ) { private val _state = MutableStateFlow(AuthState.Unauthenticated) val state: StateFlow = _state init { - refreshFromStore() + applicationScope.launch { + refreshFromStore() + } } fun refreshFromStore() { - try { - val token = tokenStore.getAccessToken() - if (token.isNullOrEmpty()) { + applicationScope.launch { + try { + val token = authLocalDataSource.getAccessToken() + if (token.isNullOrEmpty()) { + _state.value = AuthState.Unauthenticated + Timber.tag(TAG).i("[INFO] 저장소에서 토큰이 없어 비인증 상태로 설정했습니다.") + } else { + _state.value = AuthState.Authenticated(token) + Timber.tag(TAG).i("[INFO] 저장소의 토큰으로 인증 상태를 설정했습니다. (길이: ${token.length})") + } + } catch (e: Exception) { _state.value = AuthState.Unauthenticated - Timber.tag(TAG).i("[INFO] 저장소에서 토큰이 없어 비인증 상태로 설정했습니다.") - } else { - _state.value = AuthState.Authenticated(token) - Timber.tag(TAG).i("[INFO] 저장소의 토큰으로 인증 상태를 설정했습니다. (길이: ${token.length})") + Timber.tag(TAG).e(e, "[ERROR] 저장소에서 토큰을 불러오는 중 오류가 발생했습니다: ${e.message}") } - } catch (e: Exception) { - _state.value = AuthState.Unauthenticated - Log.e(TAG, "[ERROR] 저장소에서 토큰을 불러오는 중 오류가 발생했습니다: ${e.message}") } } fun setAuthenticated(accessToken: String) { - try { - tokenStore.saveAccessToken(accessToken) - _state.value = AuthState.Authenticated(accessToken) - Timber.tag(TAG).i("[INFO] 인증 완료 상태로 전환했습니다. (토큰 길이: ${accessToken.length})") - } catch (e: Exception) { - Timber.tag(TAG).e("[ERROR] 인증 상태 설정 중 오류가 발생했습니다: ${e.message}") + applicationScope.launch { + try { + authLocalDataSource.saveAccessToken( + AccessToken( + token = accessToken, + type = "Bearer", + expiresIn = LocalDateTime.now().plusHours(1), // 기본값: 1시간 후 + ), + ) + _state.value = AuthState.Authenticated(accessToken) + Timber.tag(TAG).i("[INFO] 인증 완료 상태로 전환했습니다. (토큰 길이: ${accessToken.length})") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "[ERROR] 인증 상태 설정 중 오류가 발생했습니다: ${e.message}") + } } } fun signOut() { - try { - tokenStore.clear() - _state.value = AuthState.Unauthenticated - Timber.tag(TAG).i("[INFO] 로그아웃 완료: 인증 상태를 해제했습니다.") - } catch (e: Exception) { - Timber.tag(TAG).e("[ERROR] 로그아웃 처리 중 오류가 발생했습니다: ${e.message}") + applicationScope.launch { + try { + authLocalDataSource.clearAccessToken() + _state.value = AuthState.Unauthenticated + Timber.tag(TAG).i("[INFO] 로그아웃 완료: 인증 상태를 해제했습니다.") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "[ERROR] 로그아웃 처리 중 오류가 발생했습니다: ${e.message}") + } } } diff --git a/app/src/main/java/com/autoever/everp/common/annotation/CoroutineScopeAnnotation.kt b/app/src/main/java/com/autoever/everp/common/annotation/CoroutineScopeAnnotation.kt new file mode 100644 index 0000000..b391331 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/common/annotation/CoroutineScopeAnnotation.kt @@ -0,0 +1,9 @@ +package com.autoever.everp.common.annotation + +import javax.inject.Qualifier + +// 일회용 -> 절대 쓰지 말기 +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + diff --git a/app/src/main/java/com/autoever/everp/common/annotation/RetrofitAnnotation.kt b/app/src/main/java/com/autoever/everp/common/annotation/RetrofitAnnotation.kt new file mode 100644 index 0000000..af6c15a --- /dev/null +++ b/app/src/main/java/com/autoever/everp/common/annotation/RetrofitAnnotation.kt @@ -0,0 +1,11 @@ +package com.autoever.everp.common.annotation + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthRetrofit + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class NormalRetrofit diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/AuthLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/AuthLocalDataSource.kt new file mode 100644 index 0000000..cbcc1df --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/AuthLocalDataSource.kt @@ -0,0 +1,12 @@ +package com.autoever.everp.data.datasource.local + +import com.autoever.everp.domain.model.auth.AccessToken +import kotlinx.coroutines.flow.Flow + +interface AuthLocalDataSource { + // === Access Token === + val accessTokenFlow: Flow + suspend fun getAccessToken(): String? + suspend fun saveAccessToken(token: AccessToken) + suspend fun clearAccessToken() +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/DashboardLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/DashboardLocalDataSource.kt new file mode 100644 index 0000000..3017c8c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/DashboardLocalDataSource.kt @@ -0,0 +1,11 @@ +package com.autoever.everp.data.datasource.local + +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +import kotlinx.coroutines.flow.Flow + +interface DashboardLocalDataSource { + fun observeWorkflows(): Flow + suspend fun setWorkflows(workflows: DashboardWorkflows) + suspend fun clear() +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/ImLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/ImLocalDataSource.kt new file mode 100644 index 0000000..fdd7f9c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/ImLocalDataSource.kt @@ -0,0 +1,12 @@ +package com.autoever.everp.data.datasource.local + +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import kotlinx.coroutines.flow.Flow + +interface ImLocalDataSource { + fun observeItemToggleList(): Flow> + suspend fun setItemToggleList(items: List) + suspend fun clear() +} + + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/ProfileLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/ProfileLocalDataSource.kt new file mode 100644 index 0000000..1b56677 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/ProfileLocalDataSource.kt @@ -0,0 +1,11 @@ +package com.autoever.everp.data.datasource.local + +import com.autoever.everp.domain.model.profile.Profile +import kotlinx.coroutines.flow.Flow + +interface ProfileLocalDataSource { + fun observeProfile(): Flow + suspend fun setProfile(profile: Profile) + suspend fun clear() +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/TokenLocalDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/TokenLocalDataSource.kt new file mode 100644 index 0000000..55685c5 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/TokenLocalDataSource.kt @@ -0,0 +1,20 @@ +package com.autoever.everp.data.datasource.local + +import kotlinx.coroutines.flow.Flow + +interface TokenLocalDataSource { + // === Access Token === + val accessTokenFlow: Flow + suspend fun getAccessToken(): String? + suspend fun saveAccessToken(token: String) + suspend fun clearAccessToken() + + // === FCM Token === + val fcmTokenFlow: Flow + suspend fun getFcmToken(): String? + suspend fun saveFcmToken(token: String) + suspend fun clearFcmToken() + + // === Utilities === + suspend fun clearAll() +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/.gitkeep b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt new file mode 100644 index 0000000..815c360 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt @@ -0,0 +1,80 @@ +package com.autoever.everp.data.datasource.local.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.autoever.everp.common.annotation.ApplicationScope +import com.autoever.everp.data.datasource.local.AuthLocalDataSource +import com.autoever.everp.domain.model.auth.AccessToken +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +class AuthDataStoreLocalDataSourceImpl @Inject constructor( + @ApplicationContext private val appContext: Context, + @ApplicationScope private val appScope: CoroutineScope, +) : AuthLocalDataSource { + + companion object { + private const val STORE_NAME = "everp_tokens" + private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") + private val KEY_ACCESS_TOKEN_TYPE = stringPreferencesKey("access_token_type") + private val KEY_ACCESS_TOKEN_EXPIRES_IN = stringPreferencesKey("access_token_expires_in") + } + + private val Context.dataStore: DataStore by preferencesDataStore(name = STORE_NAME) + + override val accessTokenFlow: Flow = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_ACCESS_TOKEN] } + .flowOn(Dispatchers.IO) + + + override suspend fun getAccessToken(): String? = accessTokenFlow.first() + + suspend fun getAccessTokenWithType(): String? { + // 1. dataStore에서 Preferences를 한 번만 읽어옵니다. + val prefs = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .first() + + // 2. 읽어온 Preferences 객체에서 필요한 값을 모두 꺼냅니다. + val token = prefs[KEY_ACCESS_TOKEN] + val type = prefs[KEY_ACCESS_TOKEN_TYPE] + + return if (token != null && type != null) { + "$type $token" + } else { + null + } + } + + override suspend fun saveAccessToken(token: AccessToken) { + appContext.dataStore.edit { prefs -> + prefs[KEY_ACCESS_TOKEN] = token.token + prefs[KEY_ACCESS_TOKEN_TYPE] = token.type + prefs[KEY_ACCESS_TOKEN_EXPIRES_IN] = token.expiresIn.toString() + } + } + + override suspend fun clearAccessToken() { + appContext.dataStore.edit { prefs -> + prefs.remove(KEY_ACCESS_TOKEN) + prefs.remove(KEY_ACCESS_TOKEN_TYPE) + prefs.remove(KEY_ACCESS_TOKEN_EXPIRES_IN) + // prefs.clear() + } + } + +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/TokenDataStoreLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/TokenDataStoreLocalDataSourceImpl.kt new file mode 100644 index 0000000..f25731c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/TokenDataStoreLocalDataSourceImpl.kt @@ -0,0 +1,74 @@ +package com.autoever.everp.data.datasource.local.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.autoever.everp.data.datasource.local.TokenLocalDataSource +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TokenDataStoreLocalDataSourceImpl @Inject constructor( + @ApplicationContext private val appContext: Context, +) : TokenLocalDataSource { + + companion object { + private const val STORE_NAME = "everp_tokens" + private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") + private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token") + } + + private val Context.dataStore: DataStore by preferencesDataStore(name = STORE_NAME) + + override val accessTokenFlow: Flow + get() = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_ACCESS_TOKEN] } + + override suspend fun getAccessToken(): String? = accessTokenFlow.first() + + override suspend fun saveAccessToken(token: String) { + appContext.dataStore.edit { prefs -> + prefs[KEY_ACCESS_TOKEN] = token + } + } + + override suspend fun clearAccessToken() { + appContext.dataStore.edit { prefs -> + prefs.remove(KEY_ACCESS_TOKEN) + } + } + + override val fcmTokenFlow: Flow + get() = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_FCM_TOKEN] } + + override suspend fun getFcmToken(): String? = fcmTokenFlow.first() + + override suspend fun saveFcmToken(token: String) { + appContext.dataStore.edit { prefs -> + prefs[KEY_FCM_TOKEN] = token + } + } + + override suspend fun clearFcmToken() { + appContext.dataStore.edit { prefs -> + prefs.remove(KEY_FCM_TOKEN) + } + } + + override suspend fun clearAll() { + appContext.dataStore.edit { prefs -> + prefs.remove(KEY_ACCESS_TOKEN) + prefs.remove(KEY_FCM_TOKEN) + } + } +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt index 7925cd8..59ce6b1 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/AlarmLocalDataSourceImpl.kt @@ -15,10 +15,7 @@ import javax.inject.Singleton @Singleton class AlarmLocalDataSourceImpl @Inject constructor() : AlarmLocalDataSource { private val notificationsFlow = MutableStateFlow( - value = PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false) - ), + value = PageResponse.empty() ) private val countFlow = MutableStateFlow( diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/DashboardLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/DashboardLocalDataSourceImpl.kt new file mode 100644 index 0000000..49323ff --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/DashboardLocalDataSourceImpl.kt @@ -0,0 +1,25 @@ +package com.autoever.everp.data.datasource.local.impl + +import com.autoever.everp.data.datasource.local.DashboardLocalDataSource +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +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 DashboardLocalDataSourceImpl @Inject constructor() : DashboardLocalDataSource { + private val workflowsFlow = MutableStateFlow(null) + + override fun observeWorkflows(): Flow = workflowsFlow.asStateFlow() + + override suspend fun setWorkflows(workflows: DashboardWorkflows) { + workflowsFlow.value = workflows + } + + override suspend fun clear() { + workflowsFlow.value = null + } +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt index d038ef1..10a754c 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/FcmLocalDataSourceImpl.kt @@ -20,19 +20,13 @@ class FcmLocalDataSourceImpl @Inject constructor() : FcmLocalDataSource { // AP 인보이스 캐시 private val apInvoiceListFlow = MutableStateFlow( - PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ), + PageResponse.empty(), ) private val apInvoiceDetailsFlow = MutableStateFlow>(emptyMap()) // AR 인보이스 캐시 private val arInvoiceListFlow = MutableStateFlow( - PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ), + PageResponse.empty(), ) private val arInvoiceDetailsFlow = MutableStateFlow>(emptyMap()) @@ -72,9 +66,9 @@ class FcmLocalDataSourceImpl @Inject constructor() : FcmLocalDataSource { // ========== 캐시 관리 ========== override suspend fun clearAll() { - apInvoiceListFlow.value = PageResponse(emptyList(), PageDto(0, 0, 0, 0, false)) + apInvoiceListFlow.value = PageResponse.empty() apInvoiceDetailsFlow.value = emptyMap() - arInvoiceListFlow.value = PageResponse(emptyList(), PageDto(0, 0, 0, 0, false)) + arInvoiceListFlow.value = PageResponse.empty() arInvoiceDetailsFlow.value = emptyMap() } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ImLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ImLocalDataSourceImpl.kt new file mode 100644 index 0000000..ef69b0c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ImLocalDataSourceImpl.kt @@ -0,0 +1,24 @@ +package com.autoever.everp.data.datasource.local.impl + +import com.autoever.everp.data.datasource.local.ImLocalDataSource +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +class ImLocalDataSourceImpl @Inject constructor() : ImLocalDataSource { + private val itemToggleFlow = MutableStateFlow>(emptyList()) + + override fun observeItemToggleList(): Flow> = itemToggleFlow.asStateFlow() + + override suspend fun setItemToggleList(items: List) { + itemToggleFlow.value = items + } + + override suspend fun clear() { + itemToggleFlow.value = emptyList() + } +} + + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/MmLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/MmLocalDataSourceImpl.kt index b84941a..74f90ff 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/MmLocalDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/MmLocalDataSourceImpl.kt @@ -24,10 +24,7 @@ class MmLocalDataSourceImpl @Inject constructor() : MmLocalDataSource { // 구매 주문 목록 캐시 private val purchaseOrderListFlow = MutableStateFlow( - PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ), + PageResponse.empty(), ) // 구매 주문 상세 캐시 (Map으로 관리) @@ -72,10 +69,7 @@ class MmLocalDataSourceImpl @Inject constructor() : MmLocalDataSource { override suspend fun clearAll() { supplierDetailsFlow.value = emptyMap() - purchaseOrderListFlow.value = PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ) + purchaseOrderListFlow.value = PageResponse.empty() purchaseOrderDetailsFlow.value = emptyMap() } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ProfileLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ProfileLocalDataSourceImpl.kt new file mode 100644 index 0000000..9a890b1 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/ProfileLocalDataSourceImpl.kt @@ -0,0 +1,25 @@ +package com.autoever.everp.data.datasource.local.impl + +import com.autoever.everp.data.datasource.local.ProfileLocalDataSource +import com.autoever.everp.domain.model.profile.Profile +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 ProfileLocalDataSourceImpl @Inject constructor() : ProfileLocalDataSource { + private val profileFlow = MutableStateFlow(null) + + override fun observeProfile(): Flow = profileFlow.asStateFlow() + + override suspend fun setProfile(profile: Profile) { + profileFlow.value = profile + } + + override suspend fun clear() { + profileFlow.value = null + } +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/SdLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/SdLocalDataSourceImpl.kt index 04f7239..92a0815 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/local/impl/SdLocalDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/impl/SdLocalDataSourceImpl.kt @@ -23,10 +23,7 @@ class SdLocalDataSourceImpl @Inject constructor() : SdLocalDataSource { // 견적서 목록 캐시 private val quotationListFlow = MutableStateFlow( - PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ), + PageResponse.empty(), ) // 견적서 상세 캐시 @@ -37,10 +34,7 @@ class SdLocalDataSourceImpl @Inject constructor() : SdLocalDataSource { // 주문서 목록 캐시 private val salesOrderListFlow = MutableStateFlow( - PageResponse( - content = emptyList(), - page = PageDto(0, 0, 0, 0, false), - ), + PageResponse.empty(), ) // 주문서 상세 캐시 @@ -92,10 +86,10 @@ class SdLocalDataSourceImpl @Inject constructor() : SdLocalDataSource { // ========== 캐시 관리 ========== override suspend fun clearAll() { - quotationListFlow.value = PageResponse(emptyList(), PageDto(0, 0, 0, 0, false)) + quotationListFlow.value = PageResponse.empty() quotationDetailsFlow.value = emptyMap() customerDetailsFlow.value = emptyMap() - salesOrderListFlow.value = PageResponse(emptyList(), PageDto(0, 0, 0, 0, false)) + salesOrderListFlow.value = PageResponse.empty() salesOrderDetailsFlow.value = emptyMap() } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/pref/.gitkeep b/app/src/main/java/com/autoever/everp/data/datasource/local/pref/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/src/main/java/com/autoever/everp/data/datasource/local/pref/TokenPrefLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/pref/TokenPrefLocalDataSourceImpl.kt new file mode 100644 index 0000000..f0d9609 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/pref/TokenPrefLocalDataSourceImpl.kt @@ -0,0 +1,45 @@ +package com.autoever.everp.data.datasource.local.pref + +import android.content.SharedPreferences +import com.autoever.everp.data.datasource.local.TokenLocalDataSource +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class TokenPrefLocalDataSourceImpl @Inject constructor( + private val pref: SharedPreferences, +) : TokenLocalDataSource { + override val accessTokenFlow: Flow + get() = TODO("Not yet implemented") + + override suspend fun getAccessToken(): String? { + TODO("Not yet implemented") + } + + override suspend fun saveAccessToken(token: String) { + TODO("Not yet implemented") + } + + override suspend fun clearAccessToken() { + TODO("Not yet implemented") + } + + override val fcmTokenFlow: Flow + get() = TODO("Not yet implemented") + + override suspend fun getFcmToken(): String? { + TODO("Not yet implemented") + } + + override suspend fun saveFcmToken(token: String) { + TODO("Not yet implemented") + } + + override suspend fun clearFcmToken() { + TODO("Not yet implemented") + } + + override suspend fun clearAll() { + TODO("Not yet implemented") + } + +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/AuthRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/AuthRemoteDataSource.kt new file mode 100644 index 0000000..c4516cc --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/AuthRemoteDataSource.kt @@ -0,0 +1,16 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.data.datasource.remote.http.service.LogoutResponseDto +import com.autoever.everp.data.datasource.remote.http.service.TokenResponseDto +import com.autoever.everp.domain.model.auth.AccessToken + +interface AuthRemoteDataSource { + suspend fun exchangeAuthCodeForToken( + clientId: String, + redirectUri: String, + code: String, + codeVerifier: String, + ): Result + + suspend fun logout(accessTokenWithBearer: String): Result +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/DashboardRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/DashboardRemoteDataSource.kt new file mode 100644 index 0000000..7860825 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/DashboardRemoteDataSource.kt @@ -0,0 +1,9 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.data.datasource.remote.http.service.DashboardWorkflowsResponseDto +import com.autoever.everp.domain.model.user.UserRoleEnum + +interface DashboardRemoteDataSource { + suspend fun getDashboardWorkflows(role: UserRoleEnum): Result +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt index 0e3330e..3df361e 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/FcmRemoteDataSource.kt @@ -50,8 +50,8 @@ interface FcmRemoteDataSource { request: InvoiceUpdateRequestDto, ): Result -// suspend fun completeReceivable( -// invoiceId: String, -// ): Result + suspend fun completeReceivable( + invoiceId: String, + ): Result } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt new file mode 100644 index 0000000..c025ede --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt @@ -0,0 +1,7 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.domain.model.inventory.InventoryItemToggle + +interface ImRemoteDataSource { + suspend fun getItemsToggle(): Result> +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/MmRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/MmRemoteDataSource.kt index 57c4bf9..93c01dd 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/MmRemoteDataSource.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/MmRemoteDataSource.kt @@ -1,6 +1,7 @@ package com.autoever.everp.data.datasource.remote import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.data.datasource.remote.dto.common.ToggleResponseDto import com.autoever.everp.data.datasource.remote.http.service.PurchaseOrderDetailResponseDto import com.autoever.everp.data.datasource.remote.http.service.PurchaseOrderListItemDto import com.autoever.everp.data.datasource.remote.http.service.SupplierDetailResponseDto @@ -50,4 +51,14 @@ interface MmRemoteDataSource { suspend fun getPurchaseOrderDetail( purchaseOrderId: String, ): Result + + /** + * 발주서 검색 타입 토글 조회 + */ + suspend fun getPurchaseOrderSearchTypeToggle(): Result> + + /** + * 발주서 상태 타입 토글 조회 + */ + suspend fun getPurchaseOrderStatusTypeToggle(): Result> } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt new file mode 100644 index 0000000..16a4e0d --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt @@ -0,0 +1,9 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserTypeEnum + +interface ProfileRemoteDataSource { + suspend fun getProfile(userType: UserTypeEnum): Result +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/SdRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/SdRemoteDataSource.kt index 1d8bcf9..897ed99 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/SdRemoteDataSource.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/SdRemoteDataSource.kt @@ -3,6 +3,7 @@ package com.autoever.everp.data.datasource.remote import com.autoever.everp.data.datasource.remote.dto.common.PageResponse import com.autoever.everp.data.datasource.remote.http.service.QuotationListItemDto import com.autoever.everp.data.datasource.remote.http.service.CustomerDetailResponseDto +import com.autoever.everp.data.datasource.remote.http.service.CustomerUpdateRequestDto import com.autoever.everp.data.datasource.remote.http.service.QuotationCreateRequestDto import com.autoever.everp.data.datasource.remote.http.service.QuotationDetailResponseDto import com.autoever.everp.data.datasource.remote.http.service.SalesOrderDetailResponseDto @@ -42,6 +43,11 @@ interface SdRemoteDataSource { customerId: String, ): Result + suspend fun updateCustomer( + customerId: String, + request: CustomerUpdateRequestDto, + ): Result + // ========== 주문서 ========== suspend fun getSalesOrderList( startDate: LocalDate? = null, diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/PageResponse.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/PageResponse.kt index bf6a14b..53caea0 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/PageResponse.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/PageResponse.kt @@ -8,11 +8,24 @@ import kotlinx.serialization.Serializable */ @Serializable data class PageResponse( - @SerialName("items") + @SerialName("content") val content: List, @SerialName("page") val page: PageDto, -) +) { + companion object { + fun empty(): PageResponse = PageResponse( + content = emptyList(), + page = PageDto( + number = 0, + size = 0, + totalElements = 0, + totalPages = 0, + hasNext = false, + ), + ) + } +} @Serializable data class PageDto( diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/ToggleResponseDto.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/ToggleResponseDto.kt new file mode 100644 index 0000000..ce00f04 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/ToggleResponseDto.kt @@ -0,0 +1,12 @@ +package com.autoever.everp.data.datasource.remote.dto.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ToggleResponseDto( + @SerialName("key") + val key: String, + @SerialName("value") + val value: String, +) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AlarmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AlarmHttpRemoteDataSourceImpl.kt index 1b07cb0..8057d41 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AlarmHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AlarmHttpRemoteDataSourceImpl.kt @@ -11,6 +11,9 @@ import com.autoever.everp.data.datasource.remote.http.service.NotificationMarkRe import com.autoever.everp.data.datasource.remote.http.service.NotificationReadResponseDto import com.autoever.everp.domain.model.notification.NotificationSourceEnum import com.autoever.everp.domain.model.notification.NotificationStatusEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Dispatcher import timber.log.Timber import javax.inject.Inject @@ -28,8 +31,8 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( source: NotificationSourceEnum, page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = alarmApi.getNotificationList( sortBy = sortBy.ifBlank { null }, order = order.ifBlank { null }, @@ -52,8 +55,8 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun getNotificationCount( status: NotificationStatusEnum, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = alarmApi.getNotificationCount( status = status.toApiString(), ) @@ -72,8 +75,8 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun markNotificationsAsRead( notificationIds: List, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val request = NotificationMarkReadRequestDto(notificationIds = notificationIds) val response = alarmApi.markNotificationsAsRead( request, @@ -91,8 +94,10 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun markAllNotificationsAsRead(): Result { - return try { + override suspend fun markAllNotificationsAsRead( + + ): Result = withContext(Dispatchers.IO) { + try { val response = alarmApi.markAllNotificationsAsRead() if (response.success && response.data != null) { Result.success(response.data) @@ -109,8 +114,8 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun markNotificationAsRead( notificationId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = alarmApi.markNotificationAsRead(notificationId = notificationId) if (response.success) { Result.success(Unit) @@ -129,8 +134,8 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( token: String, deviceId: String, deviceType: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val request = FcmTokenRegisterRequestDto( token = token, deviceId = deviceId, @@ -138,6 +143,7 @@ class AlarmHttpRemoteDataSourceImpl @Inject constructor( ) val response = alarmTokenApi.registerFcmToken(request) if (response.success) { + Timber.d("FCM 토큰 등록 성공: ${response.data}") Result.success(Unit) } else { Result.failure( diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AuthHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AuthHttpRemoteDataSourceImpl.kt new file mode 100644 index 0000000..17c13ff --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AuthHttpRemoteDataSourceImpl.kt @@ -0,0 +1,52 @@ +package com.autoever.everp.data.datasource.remote.http.impl + +import com.autoever.everp.data.datasource.remote.AuthRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.AuthApi +import com.autoever.everp.domain.model.auth.AccessToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject + +class AuthHttpRemoteDataSourceImpl @Inject constructor( + private val authApi: AuthApi +) : AuthRemoteDataSource { + override suspend fun exchangeAuthCodeForToken( + clientId: String, + redirectUri: String, + code: String, + codeVerifier: String, + ): Result = withContext(Dispatchers.IO) { + try { + val dto = authApi.exchangeAuthCodeForToken( + clientId = clientId, + redirectUri = redirectUri, + code = code, + codeVerifier = codeVerifier, + ) + Result.success( + AccessToken( + token = dto.accessToken, + expiresIn = LocalDateTime.now().plusSeconds(dto.expiresIn), + type = dto.tokenType, + ) + ) + } catch (e: Exception) { + Timber.tag("Auth").e(e, "exchangeAuthCodeForToken failed") + Result.failure(e) + } + } + + override suspend fun logout( + accessTokenWithBearer: String + ): Result = withContext(Dispatchers.IO) { + try { + val res = authApi.logout(accessToken = accessTokenWithBearer) + if (res.success) Result.success(Unit) else Result.failure(Exception("Logout failed")) + } catch (e: Exception) { + Timber.tag("Auth").e(e, "logout failed") + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/DashboardHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/DashboardHttpRemoteDataSourceImpl.kt new file mode 100644 index 0000000..d089a16 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/DashboardHttpRemoteDataSourceImpl.kt @@ -0,0 +1,24 @@ +package com.autoever.everp.data.datasource.remote.http.impl + +import com.autoever.everp.data.datasource.remote.DashboardRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.DashboardApi +import com.autoever.everp.data.datasource.remote.http.service.DashboardWorkflowsResponseDto +import com.autoever.everp.domain.model.user.UserRoleEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DashboardHttpRemoteDataSourceImpl @Inject constructor( + private val dashboardApi: DashboardApi, +) : DashboardRemoteDataSource { + + override suspend fun getDashboardWorkflows( + role: UserRoleEnum, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val response = dashboardApi.getDashboardWorkflows(role) + response.data ?: throw NoSuchElementException("Dashboard workflows data is null for role: $role") + } + } + +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt index f8433a7..93ba8a3 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt @@ -6,6 +6,8 @@ import com.autoever.everp.data.datasource.remote.http.service.FcmApi import com.autoever.everp.data.datasource.remote.http.service.InvoiceDetailResponseDto import com.autoever.everp.data.datasource.remote.http.service.InvoiceListItemDto import com.autoever.everp.data.datasource.remote.http.service.InvoiceUpdateRequestDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate import javax.inject.Inject @@ -24,8 +26,8 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( endDate: LocalDate?, page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = fcmApi.getApInvoiceList( // company, startDate = startDate, @@ -44,8 +46,10 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun getApInvoiceDetail(invoiceId: String): Result { - return try { + override suspend fun getApInvoiceDetail( + invoiceId: String, + ): Result = withContext(Dispatchers.IO) { + try { val response = fcmApi.getApInvoiceDetail(invoiceId) if (response.success && response.data != null) { Result.success(response.data) @@ -66,8 +70,8 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun updateApInvoice( invoiceId: String, request: InvoiceUpdateRequestDto, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = fcmApi.updateApInvoice(invoiceId) if (response.success) { Result.success(Unit) @@ -80,8 +84,10 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun requestReceivable(invoiceId: String): Result { - return try { + override suspend fun requestReceivable( + invoiceId: String, + ): Result = withContext(Dispatchers.IO) { + try { val response = fcmApi.requestReceivable(invoiceId) if (response.success) { Result.success(Unit) @@ -101,8 +107,8 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( endDate: LocalDate?, page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = fcmApi.getArInvoiceList( // companyName, startDate = startDate, @@ -121,8 +127,10 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun getArInvoiceDetail(invoiceId: String): Result { - return try { + override suspend fun getArInvoiceDetail( + invoiceId: String + ): Result = withContext(Dispatchers.IO) { + try { val response = fcmApi.getArInvoiceDetail(invoiceId) if (response.success && response.data != null) { Result.success(response.data) @@ -143,8 +151,8 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun updateArInvoice( invoiceId: String, request: InvoiceUpdateRequestDto, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = fcmApi.updateArInvoice(invoiceId) if (response.success) { Result.success(Unit) @@ -157,18 +165,18 @@ class FcmHttpRemoteDataSourceImpl @Inject constructor( } } -// override suspend fun completeReceivable(invoiceId: String): Result { -// return try { -// val response = fcmApi.completeReceivable(invoiceId) -// if (response.success) { -// Result.success(Unit) -// } else { -// Result.failure(Exception(response.message ?: "수취 완료 실패")) -// } -// } catch (e: Exception) { -// Timber.e(e, "수취 완료 실패") -// Result.failure(e) -// } -// } + override suspend fun completeReceivable(invoiceId: String): Result { + return try { + val response = fcmApi.completeReceivable(invoiceId) + if (response.success) { + Result.success(Unit) + } else { + Result.failure(Exception(response.message ?: "수취 완료 실패")) + } + } catch (e: Exception) { + Timber.e(e, "수취 완료 실패") + Result.failure(e) + } + } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt new file mode 100644 index 0000000..b5d41ce --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt @@ -0,0 +1,38 @@ +package com.autoever.everp.data.datasource.remote.http.impl + +import com.autoever.everp.data.datasource.remote.ImRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.ImApi +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class ImHttpRemoteDataSourceImpl @Inject constructor( + private val imApi: ImApi, +) : ImRemoteDataSource { + + override suspend fun getItemsToggle( + + ): Result> = withContext(Dispatchers.IO) { + try { + val response = imApi.getItemsToggle() + if (response.success && response.data != null) { + val items = response.data.products.map { dto -> + InventoryItemToggle( + itemId = dto.itemId, + itemName = dto.itemName, + uomName = dto.uomName, + unitPrice = dto.unitPrice.toLong(), // Double을 Long으로 변환 + ) + } + Result.success(items) + } else { + Result.failure( + Exception(response.message ?: "품목 목록 조회 실패") + ) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/MmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/MmHttpRemoteDataSourceImpl.kt index b586993..bfbd3d9 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/MmHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/MmHttpRemoteDataSourceImpl.kt @@ -2,6 +2,7 @@ package com.autoever.everp.data.datasource.remote.http.impl import com.autoever.everp.data.datasource.remote.MmRemoteDataSource import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.data.datasource.remote.dto.common.ToggleResponseDto import com.autoever.everp.data.datasource.remote.http.service.MmApi import com.autoever.everp.data.datasource.remote.http.service.PurchaseOrderDetailResponseDto import com.autoever.everp.data.datasource.remote.http.service.PurchaseOrderListItemDto @@ -9,6 +10,8 @@ import com.autoever.everp.data.datasource.remote.http.service.SupplierDetailResp import com.autoever.everp.data.datasource.remote.http.service.SupplierUpdateRequestDto import com.autoever.everp.domain.model.purchase.PurchaseOrderSearchTypeEnum import com.autoever.everp.domain.model.purchase.PurchaseOrderStatusEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate import javax.inject.Inject @@ -23,8 +26,8 @@ class MmHttpRemoteDataSourceImpl @Inject constructor( // ========== 공급업체 ========== override suspend fun getSupplierDetail( supplierId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = mmApi.getSupplierDetail(supplierId = supplierId) if (response.success && response.data != null) { Result.success(response.data) @@ -40,8 +43,8 @@ class MmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun updateSupplier( supplierId: String, request: SupplierUpdateRequestDto, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = mmApi.updateSupplier(supplierId = supplierId, request = request) if (response.success) { Result.success(Unit) @@ -63,8 +66,8 @@ class MmHttpRemoteDataSourceImpl @Inject constructor( endDate: LocalDate?, page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = mmApi.getPurchaseOrderList( statusCode = statusCode.toApiString(), type = type.toApiString(), @@ -87,8 +90,8 @@ class MmHttpRemoteDataSourceImpl @Inject constructor( override suspend fun getPurchaseOrderDetail( purchaseOrderId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = mmApi.getPurchaseOrderDetail(purchaseOrderId = purchaseOrderId) if (response.success && response.data != null) { Result.success(response.data) @@ -100,4 +103,32 @@ class MmHttpRemoteDataSourceImpl @Inject constructor( Result.failure(e) } } + + override suspend fun getPurchaseOrderSearchTypeToggle(): Result> = withContext(Dispatchers.IO) { + try { + val response = mmApi.getPurchaseOrderSearchTypeToggle() + if (response.success && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception(response.message ?: "발주서 검색 타입 토글 조회 실패")) + } + } catch (e: Exception) { + Timber.e(e, "발주서 검색 타입 토글 조회 실패") + Result.failure(e) + } + } + + override suspend fun getPurchaseOrderStatusTypeToggle(): Result> = withContext(Dispatchers.IO) { + try { + val response = mmApi.getPurchaseOrderStatusTypeToggle() + if (response.success && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception(response.message ?: "발주서 상태 타입 토글 조회 실패")) + } + } catch (e: Exception) { + Timber.e(e, "발주서 상태 타입 토글 조회 실패") + Result.failure(e) + } + } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ProfileHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ProfileHttpRemoteDataSourceImpl.kt new file mode 100644 index 0000000..2fc29a8 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ProfileHttpRemoteDataSourceImpl.kt @@ -0,0 +1,59 @@ +package com.autoever.everp.data.datasource.remote.http.impl + +import com.autoever.everp.data.datasource.remote.ProfileRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.CustomerProfileResponseDto +import com.autoever.everp.data.datasource.remote.http.service.ProfileApi +import com.autoever.everp.data.datasource.remote.http.service.SupplierProfileResponseDto +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserTypeEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class ProfileHttpRemoteDataSourceImpl @Inject constructor( + private val profileApi: ProfileApi, +) : ProfileRemoteDataSource { + + override suspend fun getProfile( + userType: UserTypeEnum, + ): Result = withContext(Dispatchers.IO) { + runCatching { + when (userType) { + UserTypeEnum.CUSTOMER -> { + val response = profileApi.getCustomerProfile() + response.data?.let { dto: CustomerProfileResponseDto -> + Profile( + userName = dto.customerName, + userEmail = dto.email, + userPhoneNumber = dto.phoneNumber, + companyName = dto.companyName, + businessNumber = dto.businessNumber, + baseAddress = dto.baseAddress, + detailAddress = dto.detailAddress, + officePhone = dto.officePhone, + ) + } ?: throw Exception("Customer profile data is null") + } + + UserTypeEnum.SUPPLIER -> { + val response = profileApi.getSupplierProfile() + response.data?.let { dto: SupplierProfileResponseDto -> + Profile( + userName = dto.supplierUserName, + userEmail = dto.supplierUserEmail, + userPhoneNumber = dto.supplierUserPhoneNumber, + companyName = dto.companyName, + businessNumber = dto.businessNumber, + baseAddress = dto.baseAddress, + detailAddress = dto.detailAddress, + officePhone = dto.officePhone, + ) + } ?: throw Exception("Supplier profile data is null") + } + + else -> throw Exception("Unsupported user type: $userType") + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/SdHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/SdHttpRemoteDataSourceImpl.kt index e6faf49..4866d94 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/SdHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/SdHttpRemoteDataSourceImpl.kt @@ -4,6 +4,7 @@ import com.autoever.everp.data.datasource.remote.SdRemoteDataSource import com.autoever.everp.data.datasource.remote.dto.common.PageResponse import com.autoever.everp.data.datasource.remote.http.service.QuotationListItemDto import com.autoever.everp.data.datasource.remote.http.service.CustomerDetailResponseDto +import com.autoever.everp.data.datasource.remote.http.service.CustomerUpdateRequestDto import com.autoever.everp.data.datasource.remote.http.service.QuotationCreateRequestDto import com.autoever.everp.data.datasource.remote.http.service.QuotationDetailResponseDto import com.autoever.everp.data.datasource.remote.http.service.SalesOrderDetailResponseDto @@ -13,6 +14,8 @@ import com.autoever.everp.domain.model.quotation.QuotationSearchTypeEnum import com.autoever.everp.domain.model.quotation.QuotationStatusEnum import com.autoever.everp.domain.model.sale.SalesOrderSearchTypeEnum import com.autoever.everp.domain.model.sale.SalesOrderStatusEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDate import javax.inject.Inject @@ -34,8 +37,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( sort: String, // Busintess/sd Quotation Enity의 sort와 동일 page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = sdApi.getQuotationList( startDate = startDate, endDate = endDate, @@ -59,8 +62,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( override suspend fun getQuotationDetail( quotationId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = sdApi.getQuotationDetail(quotationId = quotationId) if (response.success && response.data != null) { Result.success(response.data) @@ -75,8 +78,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( override suspend fun createQuotation( request: QuotationCreateRequestDto, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = sdApi.createQuotation(request = request) if (response.success && response.data != null) { Result.success(response.data.quotationId) @@ -92,8 +95,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( // ========== 고객사 ========== override suspend fun getCustomerDetail( customerId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = sdApi.getCustomerDetail(customerId = customerId) if (response.success && response.data != null) { Result.success(response.data) @@ -106,6 +109,23 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( } } + override suspend fun updateCustomer( + customerId: String, + request: CustomerUpdateRequestDto, + ): Result = withContext(Dispatchers.IO) { + try { + val response = sdApi.updateCustomer(customerId = customerId, request = request) + if (response.success) { + Result.success(Unit) + } else { + Result.failure(Exception(response.message ?: "고객사 수정 실패")) + } + } catch (e: Exception) { + Timber.e(e, "고객사 수정 실패") + Result.failure(e) + } + } + // ========== 주문서 ========== override suspend fun getSalesOrderList( startDate: LocalDate?, @@ -115,8 +135,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( status: SalesOrderStatusEnum, page: Int, size: Int, - ): Result> { - return try { + ): Result> = withContext(Dispatchers.IO) { + try { val response = sdApi.getSalesOrderList( startDate = startDate, endDate = endDate, @@ -139,8 +159,8 @@ class SdHttpRemoteDataSourceImpl @Inject constructor( override suspend fun getSalesOrderDetail( salesOrderId: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val response = sdApi.getSalesOrderDetail(salesOrderId = salesOrderId) if (response.success && response.data != null) { Result.success(response.data) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/UserHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/UserHttpRemoteDataSourceImpl.kt index c9b0c7a..5ff5ec0 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/UserHttpRemoteDataSourceImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/UserHttpRemoteDataSourceImpl.kt @@ -3,6 +3,8 @@ package com.autoever.everp.data.datasource.remote.http.impl import com.autoever.everp.data.datasource.remote.UserRemoteDataSource import com.autoever.everp.data.datasource.remote.http.service.UserApi import com.autoever.everp.data.datasource.remote.http.service.UserInfoResponseDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject @@ -13,8 +15,10 @@ class UserHttpRemoteDataSourceImpl @Inject constructor( private val userApi: UserApi, ) : UserRemoteDataSource { - override suspend fun getUserInfo(): Result { - return try { + override suspend fun getUserInfo( + + ): Result = withContext(Dispatchers.IO) { + try { val response = userApi.getUserInfo() if (response.success && response.data != null) { Result.success(response.data) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmApi.kt index 7a85365..784aa8b 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmApi.kt @@ -75,15 +75,15 @@ data class NotificationListItemDto( @SerialName("notificationMessage") val message: String, @SerialName("linkType") - val linkType: NotificationLinkEnum, + val linkType: NotificationLinkEnum = NotificationLinkEnum.UNKNOWN, @SerialName("linkId") val linkId: String, @SerialName("source") val source: String, - @SerialName("status") - val status: String, @SerialName("createdAt") val createdAt: String, + @SerialName("isRead") + val isRead: Boolean, ) @Serializable diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmTokenApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmTokenApi.kt index 6153766..8e6a5de 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmTokenApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AlarmTokenApi.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import retrofit2.http.Body import retrofit2.http.POST +import java.io.Serial /** * FCM 토큰 관련 API Service @@ -19,7 +20,7 @@ interface AlarmTokenApi { @POST("$BASE_URL/register") suspend fun registerFcmToken( @Body request: FcmTokenRegisterRequestDto, - ): ApiResponse + ): ApiResponse companion object { private const val BASE_URL = "alarm/fcm-tokens" @@ -37,3 +38,23 @@ data class FcmTokenRegisterRequestDto( val deviceType: String = "ANDROID", ) +@Serializable +data class FcmTokenRegisterResponseDto( + @SerialName("id") + val tokenRegisterId: String, + @SerialName("userId") + val userId: String, + @SerialName("fcmToken") + val fcmToken: String, + @SerialName("deviceId") + val deviceId: String, + @SerialName("deviceType") + val deviceType: String, + @SerialName("isActive") + val isActive: Boolean, + @SerialName("createdAt") + val createdAt: String? = null, + @SerialName("updatedAt") + val updatedAt: String? = null, +) + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AuthApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AuthApi.kt new file mode 100644 index 0000000..ffee6d6 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AuthApi.kt @@ -0,0 +1,79 @@ +package com.autoever.everp.data.datasource.remote.http.service + +import android.net.Uri +import com.autoever.everp.BuildConfig +import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Query +import androidx.core.net.toUri + +interface AuthApi { + + @POST(TOKEN_URL) + suspend fun exchangeAuthCodeForToken( + @Header("Content-Type") contentType: String = "application/x-www-form-urlencoded", + @Query("grant_type") grantType: String = "authorization_code", + @Query("client_id") clientId: String, + @Query("redirect_uri") redirectUri: String, + @Query("code") code: String, + @Query("code_verifier") codeVerifier: String, + ): TokenResponseDto + + @POST(LOGOUT_URL) + suspend fun logout( + @Header("Authorization") accessToken: String, // Bearer 포함하여 전달 + ): ApiResponse + + + fun getAuthorizationUrl(): String = "$AUTH_BASE_URL$AUTHORIZATION_URL" + + companion object { + private const val AUTH_BASE_URL = BuildConfig.AUTH_BASE_URL + private const val AUTHORIZATION_URL = "oauth2/authorize" + private const val TOKEN_URL = "oauth2/token" + private const val LOGOUT_URL = "logout" + + fun generateAuthorizationUrl( + responseType: String = "code", + clientId: String = "everp-aos", + redirectUri: String = "everp-aos://callback", + scope: String = "openid profile email", + state: String, + codeChallenge: String, + codeChallengeMethod: String = "S256", + ): String { + return "$AUTH_BASE_URL$AUTHORIZATION_URL".toUri() + .buildUpon() // Uri.Builder 사용 + .appendQueryParameter("response_type", responseType) + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("redirect_uri", redirectUri) + .appendQueryParameter("scope", scope) + .appendQueryParameter("state", state) + .appendQueryParameter("code_challenge", codeChallenge) + .appendQueryParameter("code_challenge_method", codeChallengeMethod) + .build() + .toString() + } + } +} + +@Serializable +data class TokenResponseDto( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("scope") + val scope: String, +) + +@Serializable +data class LogoutResponseDto( + @SerialName("success") + val success: Boolean, +) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt new file mode 100644 index 0000000..f4e8732 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt @@ -0,0 +1,69 @@ +package com.autoever.everp.data.datasource.remote.http.service + + +import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.domain.model.user.UserRoleEnum +import com.autoever.everp.utils.serializer.LocalDateSerializer +import com.autoever.everp.utils.serializer.LocalDateTimeSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.GET +import retrofit2.http.Query +import java.time.LocalDate +import java.time.LocalDateTime + +interface DashboardApi { + + /** + * 대시보드 워크플로우 조회 + */ + @GET("$BASE_URL/workflows") + suspend fun getDashboardWorkflows( + @Query("role") role: UserRoleEnum, + ): ApiResponse + + companion object { + private const val BASE_URL = "dashboard" + } +} + +@Serializable +data class DashboardWorkflowsResponseDto( + @SerialName("tabs") + val tabs: List, +) { + @Serializable + data class DashboardWorkflowTabDto( + @SerialName("tabCode") + val tabCode: DashboardTapEnum, + @SerialName("items") + val items: List, + ) { + @Serializable + data class DashboardWorkflowTabItemDto( + @SerialName("itemId") + val itemId: String, + @SerialName("itemNumber") + val itemNumber: String, + @SerialName("itemTitle") + val itemTitle: String, // workflow name + @SerialName("name") + val name: String, // 고객명 or 공급사명 + @SerialName("statusCode") + val statusCode: String, + @SerialName("date") +// @Serializable(with = LocalDateSerializer::class) +// val date: LocalDate, + val date: String, + ) + } +} + + +/* + +@GET("$BASE_URL/statistics") + suspend fun getDashboardStatistics(): DashboardStatisticsResponseDto + + */ diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt index a80b40d..4312b4a 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt @@ -81,10 +81,10 @@ interface FcmApi { // @Body request: InvoiceUpdateRequestDto, ): ApiResponse -// @POST("$BASE_URL/invoice/ar/{invoiceId}/receivable/complete") -// suspend fun completeReceivable( -// @Path("invoiceId") invoiceId: String, -// ): ApiResponse + @POST("$BASE_URL/invoice/ar/{invoiceId}/receivable/complete") + suspend fun completeReceivable( + @Path("invoiceId") invoiceId: String, + ): ApiResponse companion object { private const val BASE_URL = "business/fcm" @@ -100,14 +100,14 @@ data class InvoiceListItemDto( @SerialName("connection") val connection: InvoiceConnectionDto, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, @SerialName("issueDate") @Serializable(with = LocalDateSerializer::class) val issueDate: LocalDate, @SerialName("dueDate") @Serializable(with = LocalDateSerializer::class) val dueDate: LocalDate, - @SerialName("status") + @SerialName("statusCode") val statusCode: InvoiceStatusEnum, // PENDING, PAID, UNPAID @SerialName("reference") val reference: InvoiceReferenceDto, @@ -152,7 +152,7 @@ data class InvoiceDetailResponseDto( @SerialName("referenceNumber") val referenceNumber: String, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, @SerialName("note") val note: String, @SerialName("items") @@ -170,9 +170,9 @@ data class InvoiceDetailItemDto( @SerialName("unitOfMaterialName") val unitOfMaterialName: String, @SerialName("unitPrice") - val unitPrice: Long, + val unitPrice: Double, @SerialName("totalPrice") - val totalPrice: Long, + val totalPrice: Double, ) // TODO 전표 업데이트시 필요한지 확인 필요 - 기본값이 있는 경우 값이 전달되지 않음 diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ImApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ImApi.kt index eb0b684..e1d6db4 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ImApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ImApi.kt @@ -1,16 +1,53 @@ package com.autoever.everp.data.datasource.remote.http.service +import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.GET + /** * 재고 관리(IM, Inventory Management) API Service * Base URL: /scm-pp/iv */ interface ImApi { + /** + * 재고 아이템 토글 목록 조회 + */ + @GET("scm-pp/product/item/toggle") + suspend fun getItemsToggle( + + ): ApiResponse + companion object { private const val BASE_URL = "scm-pp/iv" } } +@Serializable +data class ItemToggleListResponseDto( + @SerialName("products") + val products: List, +) + +@Serializable +data class ItemToggleListItemDto( + @SerialName("itemId") + val itemId: String, + @SerialName("itemNumber") + val itemNumber: String, + @SerialName("itemName") + val itemName: String, + @SerialName("uomName") + val uomName: String, + @SerialName("unitPrice") + val unitPrice: Double, +// @SerialName("supplierCompanyId") +// val supplierCompanyId: String, +// @SerialName("supplierCompanyName") +// val supplierCompanyName: String, +) + /* // ========== 재고 아이템 관리 ========== @GET("$BASE_URL/inventory-items") @@ -134,9 +171,6 @@ suspend fun getStatistics(): ApiResponse @GET("$BASE_URL/warehouses/statistic") suspend fun getWarehouseStatistics(): ApiResponse -@GET("$BASE_URL/items/toggle") -suspend fun getItemsToggle(): ApiResponse - =========== 재고 관리 ========== @Serializable data class AddInventoryItemRequestDto( diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/MmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/MmApi.kt index c1e6e38..567dd6e 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/MmApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/MmApi.kt @@ -2,10 +2,12 @@ package com.autoever.everp.data.datasource.remote.http.service import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.data.datasource.remote.dto.common.ToggleResponseDto import com.autoever.everp.domain.model.purchase.PurchaseOrderStatusEnum import com.autoever.everp.domain.model.supplier.SupplierCategoryEnum import com.autoever.everp.domain.model.supplier.SupplierStatusEnum import com.autoever.everp.utils.serializer.LocalDateSerializer +import com.autoever.everp.utils.serializer.LocalDateTimeSerializer import kotlinx.serialization.Required import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -15,6 +17,7 @@ import retrofit2.http.PATCH import retrofit2.http.Path import retrofit2.http.Query import java.time.LocalDate +import java.time.LocalDateTime /** * 자재 관리(MM, Materials Management) API Service @@ -65,6 +68,22 @@ interface MmApi { @Path("purchaseOrderId") purchaseOrderId: String, ): ApiResponse + /** + * 발주서 검색 타입 토글 조회 + */ + @GET("$BASE_URL/purchase-orders/search-type/toggle") + suspend fun getPurchaseOrderSearchTypeToggle( + + ): ApiResponse> + + /** + * 발주서 상태 타입 토글 조회 + */ + @GET("$BASE_URL/purchase-orders/status/toggle") + suspend fun getPurchaseOrderStatusTypeToggle( + + ): ApiResponse> + companion object { private const val BASE_URL = "scm-pp/mm" } @@ -152,17 +171,17 @@ data class PurchaseOrderListItemDto( @SerialName("purchaseOrderNumber") val purchaseOrderNumber: String, @SerialName("supplierName") - val supplierName: String, + val supplierName: String = "", // TODO 임시 필드, API 수정 필요 @SerialName("itemsSummary") val itemsSummary: String, - @Serializable(with = LocalDateSerializer::class) + @Serializable(with = LocalDateTimeSerializer::class) @SerialName("orderDate") - val orderDate: LocalDate, - @Serializable(with = LocalDateSerializer::class) + val orderDate: LocalDateTime, + @Serializable(with = LocalDateTimeSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: LocalDateTime, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, @SerialName("statusCode") val statusCode: PurchaseOrderStatusEnum = PurchaseOrderStatusEnum.UNKNOWN, ) @@ -175,12 +194,12 @@ data class PurchaseOrderDetailResponseDto( val purchaseOrderNumber: String, @SerialName("statusCode") val statusCode: PurchaseOrderStatusEnum = PurchaseOrderStatusEnum.UNKNOWN, - @Serializable(with = LocalDateSerializer::class) + @Serializable(with = LocalDateTimeSerializer::class) @SerialName("orderDate") - val orderDate: LocalDate, - @Serializable(with = LocalDateSerializer::class) + val orderDate: LocalDateTime, + @Serializable(with = LocalDateTimeSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: LocalDateTime, @SerialName("supplierId") val supplierId: String, @SerialName("supplierNumber") @@ -194,7 +213,7 @@ data class PurchaseOrderDetailResponseDto( @SerialName("items") val items: List, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, @SerialName("note") val note: String? = null, ) @@ -206,11 +225,11 @@ data class PurchaseOrderDetailItemDto( @SerialName("itemName") val itemName: String, @SerialName("quantity") - val quantity: Int, + val quantity: Double, @SerialName("uomName") val uomName: String, @SerialName("unitPrice") - val unitPrice: Long, + val unitPrice: Double, @SerialName("totalPrice") - val totalPrice: Long, + val totalPrice: Double, ) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt new file mode 100644 index 0000000..adfd4c3 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt @@ -0,0 +1,64 @@ +package com.autoever.everp.data.datasource.remote.http.service + +import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.GET + +interface ProfileApi { + + @GET(BASE_URL) + suspend fun getCustomerProfile( + + ): ApiResponse + + @GET(BASE_URL) + suspend fun getSupplierProfile( + + ): ApiResponse + + + companion object { + private const val BASE_URL = "business/profile" + } +} + +@Serializable +data class CustomerProfileResponseDto( + @SerialName("customerName") + val customerName: String, + @SerialName("email") + val email: String, + @SerialName("phoneNumber") + val phoneNumber: String, + @SerialName("companyName") + val companyName: String, + @SerialName("businessNumber") + val businessNumber: String, + @SerialName("baseAddress") + val baseAddress: String, + @SerialName("detailAddress") + val detailAddress: String, + @SerialName("officePhone") + val officePhone: String, +) + +@Serializable +data class SupplierProfileResponseDto( + @SerialName("supplierUserName") + val supplierUserName: String, + @SerialName("supplierUserEmail") + val supplierUserEmail: String, + @SerialName("supplierUserPhoneNumber") + val supplierUserPhoneNumber: String, + @SerialName("companyName") + val companyName: String, + @SerialName("businessNumber") + val businessNumber: String, + @SerialName("baseAddress") + val baseAddress: String, + @SerialName("detailAddress") + val detailAddress: String, + @SerialName("officePhone") + val officePhone: String, +) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt index 43cdda8..7997646 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt @@ -110,14 +110,14 @@ interface SdApi { @Serializable data class QuotationListItemDto( @SerialName("quotationId") - val quotationId: Long, + val quotationId: String, @SerialName("quotationNumber") val quotationNumber: String, @SerialName("customerName") val customerName: String, - @Serializable(with = LocalDateSerializer::class) +// @Serializable(with = LocalDateSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: String? = null, @SerialName("statusCode") val statusCode: QuotationStatusEnum = QuotationStatusEnum.UNKNOWN, @SerialName("productId") @@ -149,7 +149,7 @@ data class QuotationDetailResponseDto( @SerialName("items") val items: List, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, ) @Serializable @@ -163,16 +163,16 @@ data class QuotationItemDto( @SerialName("uomName") val uomName: String, @SerialName("unitPrice") - val unitPrice: Long, - @SerialName("totalPrice") - val totalPrice: Long, + val unitPrice: Double, + @SerialName("amount") + val totalPrice: Double, ) @Serializable data class QuotationCreateRequestDto( @Serializable(with = LocalDateSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: LocalDate? = null, @SerialName("items") val items: List, @SerialName("note") @@ -228,7 +228,7 @@ data class CustomerDetailResponseDto( val totalOrders: Long, // 총 거래 금액 @SerialName("totalTransactionAmount") - val totalTransactionAmount: Long, + val totalTransactionAmount: Double, @SerialName("note") val note: String? = null, ) @@ -286,7 +286,7 @@ data class SalesOrderListItemDto( @SerialName("dueDate") val dueDate: LocalDate, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, @SerialName("statusCode") val statusCode: SalesOrderStatusEnum, ) @@ -309,9 +309,9 @@ data class SalesOrderCustomerDto( val customerId: String, @SerialName("customerName") val customerName: String, - @SerialName("baseAddress") + @SerialName("customerBaseAddress") val baseAddress: String, - @SerialName("detailAddress") + @SerialName("customerDetailAddress") val detailAddress: String, @SerialName("manager") val manager: CustomerManagerDto, @@ -332,7 +332,7 @@ data class SalesOrderDetailDto( @SerialName("statusCode") val statusCode: SalesOrderStatusEnum, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, ) @Serializable @@ -343,9 +343,11 @@ data class SalesOrderItemDto( val itemName: String, @SerialName("quantity") val quantity: Int, + @SerialName("uonName") + val uomName: String, @SerialName("unitPrice") val unitPrice: Long, - @SerialName("totalPrice") + @SerialName("amount") val totalPrice: Long, ) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/interceptor/AuthInterceptor.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/interceptor/AuthInterceptor.kt index 2fe2eae..87a5fa5 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/interceptor/AuthInterceptor.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/interceptor/AuthInterceptor.kt @@ -1,31 +1,54 @@ package com.autoever.everp.data.datasource.remote.interceptor +import com.autoever.everp.common.annotation.ApplicationScope +import com.autoever.everp.data.datasource.local.AuthLocalDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import okhttp3.Interceptor import okhttp3.Response +import timber.log.Timber +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton /** * JWT 토큰을 자동으로 헤더에 추가하는 Interceptor + * DataStore의 Flow를 구독하여 토큰 변경을 실시간으로 반영합니다. */ @Singleton class AuthInterceptor @Inject constructor( - // TODO: TokenManager 또는 DataStore를 주입받아 토큰 관리 - // private val tokenManager: TokenManager + private val authLocalDataSource: AuthLocalDataSource, + @ApplicationScope private val applicationScope: CoroutineScope, ) : Interceptor { + // Flow에서 받은 토큰을 동기적으로 접근 가능한 변수에 저장 + private val currentToken = AtomicReference() + + init { + // Flow를 구독하여 토큰 변경을 실시간으로 반영 + authLocalDataSource.accessTokenFlow + .onEach { token -> + currentToken.set(token) + Timber.tag(TAG).d("Access token updated: ${if (token != null) "present (length: ${token.length})" else "null"}") + } + .launchIn(applicationScope) + } + override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - // TODO: 실제 토큰 가져오기 로직 구현 - val token = getAccessToken() + // Flow에서 구독한 현재 토큰 값 사용 + val token = currentToken.get() - val newRequest = if (token != null) { + val newRequest = if (token != null && token.isNotBlank()) { + // 토큰이 존재하면 Authorization 헤더 추가 originalRequest.newBuilder() .header("Authorization", "Bearer $token") .header("Content-Type", "application/json") .build() } else { + // 토큰이 없으면 원본 요청 유지 (Content-Type만 추가) originalRequest.newBuilder() .header("Content-Type", "application/json") .build() @@ -34,10 +57,8 @@ class AuthInterceptor @Inject constructor( return chain.proceed(newRequest) } - private fun getAccessToken(): String? { - // TODO: DataStore 또는 SharedPreferences에서 토큰 가져오기 - // return tokenManager.getAccessToken() - return null + private companion object { + const val TAG = "AuthInterceptor" } } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/DashboardMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/DashboardMapper.kt new file mode 100644 index 0000000..13a4736 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/DashboardMapper.kt @@ -0,0 +1,24 @@ +package com.autoever.everp.data.datasource.remote.mapper + +import com.autoever.everp.data.datasource.remote.http.service.DashboardWorkflowsResponseDto +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows + +object DashboardMapper { + fun toDomain(dto: DashboardWorkflowsResponseDto): DashboardWorkflows = + DashboardWorkflows( + tabs = dto.tabs.flatMap { tab -> + tab.items.map { item -> + DashboardWorkflows.DashboardWorkflowTab( + tabCode = tab.tabCode, + id = item.itemId, + number = item.itemNumber, + description = item.itemTitle, + createdBy = item.name, + status = item.statusCode, + createdAt = item.date, + ) + } + } + ) +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ImMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ImMapper.kt new file mode 100644 index 0000000..c95d62e --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ImMapper.kt @@ -0,0 +1,18 @@ +package com.autoever.everp.data.datasource.remote.mapper + +import com.autoever.everp.data.datasource.remote.http.service.ItemToggleListItemDto +import com.autoever.everp.domain.model.inventory.InventoryItemToggle + +object ImMapper { + fun toDomain(dto: ItemToggleListItemDto): InventoryItemToggle = + InventoryItemToggle( + itemId = dto.itemId, + itemName = dto.itemName, + uomName = dto.uomName, + unitPrice = dto.unitPrice.toLong(), +// supplierCompanyId = dto.supplierCompanyId, +// supplierCompanyName = dto.supplierCompanyName, + ) +} + + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/InvoiceMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/InvoiceMapper.kt index 19b85ae..89e975d 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/InvoiceMapper.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/InvoiceMapper.kt @@ -29,7 +29,7 @@ object InvoiceMapper { id = dto.invoiceId, number = dto.invoiceNumber, connection = connection, - totalAmount = dto.totalAmount, + totalAmount = dto.totalAmount.toLong(), dueDate = dto.dueDate, status = dto.statusCode, reference = reference, @@ -44,8 +44,8 @@ object InvoiceMapper { name = it.itemName, quantity = it.quantity, unitOfMaterialName = it.unitOfMaterialName, - unitPrice = it.unitPrice, - totalPrice = it.totalPrice, + unitPrice = it.unitPrice.toLong(), + totalPrice = it.totalPrice.toLong(), ) } @@ -58,7 +58,7 @@ object InvoiceMapper { dueDate = dto.dueDate, connectionName = dto.connectionName, referenceNumber = dto.referenceNumber, - totalAmount = dto.totalAmount, + totalAmount = dto.totalAmount.toLong(), note = dto.note, items = items, ) diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/NotificationMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/NotificationMapper.kt index 02bc1ec..7d4ff9e 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/NotificationMapper.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/NotificationMapper.kt @@ -20,7 +20,7 @@ object NotificationMapper { val source = NotificationSourceEnum.fromStringOrNull(dto.source) ?: NotificationSourceEnum.UNKNOWN - val status = NotificationStatusEnum.fromStringOrDefault(dto.status) + val status = dto.isRead.let { if (it) NotificationStatusEnum.READ else NotificationStatusEnum.UNREAD } // createdAt 파싱 (ISO 8601 형식 가정) val createdAt = try { diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt new file mode 100644 index 0000000..dd80b5a --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt @@ -0,0 +1,32 @@ +package com.autoever.everp.data.datasource.remote.mapper + +import com.autoever.everp.data.datasource.remote.http.service.CustomerProfileResponseDto +import com.autoever.everp.data.datasource.remote.http.service.SupplierProfileResponseDto +import com.autoever.everp.domain.model.profile.Profile + +object ProfileMapper { + fun toDomain(dto: SupplierProfileResponseDto): Profile = + Profile( + userName = dto.supplierUserName, + userEmail = dto.supplierUserEmail, + userPhoneNumber = dto.supplierUserPhoneNumber, + companyName = dto.companyName, + businessNumber = dto.businessNumber, + baseAddress = dto.baseAddress, + detailAddress = dto.detailAddress, + officePhone = dto.officePhone, + ) + + fun toDomain(dto: CustomerProfileResponseDto): Profile = + Profile( + userName = dto.customerName, + userEmail = dto.email, + userPhoneNumber = dto.phoneNumber, + companyName = dto.companyName, + businessNumber = dto.businessNumber, + baseAddress = dto.baseAddress, + detailAddress = dto.detailAddress, + officePhone = dto.officePhone, + ) +} + diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/PurchaseOrderMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/PurchaseOrderMapper.kt index f9163eb..08c50df 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/PurchaseOrderMapper.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/PurchaseOrderMapper.kt @@ -20,9 +20,9 @@ object PurchaseOrderMapper { number = dto.purchaseOrderNumber, supplierName = dto.supplierName, itemsSummary = dto.itemsSummary, - orderDate = dto.orderDate, - dueDate = dto.dueDate, - totalAmount = dto.totalAmount, + orderDate = dto.orderDate.toLocalDate(), + dueDate = dto.dueDate.toLocalDate(), + totalAmount = dto.totalAmount.toLong(), status = dto.statusCode, ) } @@ -46,11 +46,11 @@ object PurchaseOrderMapper { id = dto.purchaseOrderId, number = dto.purchaseOrderNumber, status = dto.statusCode, - orderDate = dto.orderDate, - dueDate = dto.dueDate, + orderDate = dto.orderDate.toLocalDate(), + dueDate = dto.dueDate.toLocalDate(), supplier = supplier, items = dto.items.map { toItemDomain(it) }, - totalAmount = dto.totalAmount, + totalAmount = dto.totalAmount.toLong(), note = dto.note ?: "", ) } @@ -62,10 +62,10 @@ object PurchaseOrderMapper { return PurchaseOrderDetail.PurchaseOrderDetailItem( id = dto.itemId, name = dto.itemName, - quantity = dto.quantity, + quantity = dto.quantity.toInt(), uomName = dto.uomName, - unitPrice = dto.unitPrice, - totalPrice = dto.totalPrice, + unitPrice = dto.unitPrice.toLong(), + totalPrice = dto.totalPrice.toLong(), ) } diff --git a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/SdMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/SdMapper.kt index aa441e7..3294a20 100644 --- a/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/SdMapper.kt +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/SdMapper.kt @@ -13,6 +13,7 @@ import com.autoever.everp.domain.model.quotation.QuotationDetail import com.autoever.everp.domain.model.quotation.QuotationListItem import com.autoever.everp.domain.model.sale.SalesOrderDetail import com.autoever.everp.domain.model.sale.SalesOrderListItem +import java.time.LocalDate /** * SD(영업 관리) DTO to Domain Model Mapper @@ -32,11 +33,11 @@ object SdMapper { ) return QuotationListItem( - id = dto.quotationId.toString(), + id = dto.quotationId, number = dto.quotationNumber, customer = customer, status = dto.statusCode, - dueDate = dto.dueDate, + dueDate = parseLocalDateSafely(dto.dueDate), product = product, ) } @@ -53,8 +54,8 @@ object SdMapper { name = it.itemName, quantity = it.quantity, uomName = it.uomName, - unitPrice = it.unitPrice, - totalPrice = it.totalPrice, + unitPrice = it.unitPrice.toLong(), + totalPrice = it.totalPrice.toLong(), ) } @@ -64,7 +65,7 @@ object SdMapper { issueDate = dto.quotationDate, dueDate = dto.dueDate, status = dto.statusCode, - totalAmount = dto.totalAmount, + totalAmount = dto.totalAmount.toLong(), customer = customer, items = items, ) @@ -101,7 +102,7 @@ object SdMapper { managerPhone = dto.manager.managerPhone, managerEmail = dto.manager.managerEmail, totalOrders = dto.totalOrders, - totalTransactionAmount = dto.totalTransactionAmount, + totalTransactionAmount = dto.totalTransactionAmount.toLong(), note = dto.note, ) } @@ -117,7 +118,7 @@ object SdMapper { managerEmail = dto.customerManager.managerEmail, orderDate = dto.orderDate, dueDate = dto.dueDate, - totalAmount = dto.totalAmount, + totalAmount = dto.totalAmount.toLong(), statusCode = dto.statusCode, ) } @@ -129,7 +130,7 @@ object SdMapper { orderDate = dto.order.orderDate, dueDate = dto.order.dueDate, statusCode = dto.order.statusCode, - totalAmount = dto.order.totalAmount, + totalAmount = dto.order.totalAmount.toLong(), customerId = dto.customer.customerId, customerName = dto.customer.customerName, baseAddress = dto.customer.baseAddress, @@ -142,6 +143,7 @@ object SdMapper { itemId = it.itemId, itemName = it.itemName, quantity = it.quantity, + uomName = it.uomName, unitPrice = it.unitPrice, totalPrice = it.totalPrice, ) @@ -153,5 +155,18 @@ object SdMapper { fun salesOrderListToDomainList(dtoList: List): List { return dtoList.map { salesOrderListToDomain(it) } } + + // ========== 안전한 날짜 파싱 ========== + private fun parseLocalDateSafely(dateString: String?): LocalDate? { + return try { + if (dateString.isNullOrBlank() || dateString == "-") { + null + } else { + LocalDate.parse(dateString) + } + } catch (e: Exception) { + null + } + } } diff --git a/app/src/main/java/com/autoever/everp/data/repository/AlarmRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/AlarmRepositoryImpl.kt index 1836e2e..4c5d6d9 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/AlarmRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/AlarmRepositoryImpl.kt @@ -9,8 +9,11 @@ import com.autoever.everp.domain.model.notification.NotificationCount import com.autoever.everp.domain.model.notification.NotificationListParams import com.autoever.everp.domain.model.notification.NotificationStatusEnum import com.autoever.everp.domain.repository.AlarmRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -26,16 +29,16 @@ class AlarmRepositoryImpl @Inject constructor( override suspend fun refreshNotifications( params: NotificationListParams, - ): Result { - return getNotificationList(params).map { page -> + ): Result = withContext(Dispatchers.Default) { + getNotificationList(params).map { page -> alarmLocalDataSource.setNotifications(page) } } override suspend fun getNotificationList( params: NotificationListParams, - ): Result> { - return alarmRemoteDataSource.getNotificationList( + ): Result> = withContext(Dispatchers.Default) { + alarmRemoteDataSource.getNotificationList( sortBy = params.sortBy, order = params.order, source = params.source, @@ -53,19 +56,40 @@ class AlarmRepositoryImpl @Inject constructor( override fun observeNotificationCount(): Flow = alarmLocalDataSource.observeNotificationCount() - override suspend fun refreshNotificationCount( - status: NotificationStatusEnum - ): Result { - return getNotificationCount(status).map { count -> + override suspend fun refreshNotificationCount(): Result { + return getNotificationCount().map { count -> alarmLocalDataSource.setNotificationCount(count) } } override suspend fun getNotificationCount( - status: NotificationStatusEnum - ): Result { - return alarmRemoteDataSource.getNotificationCount(status = status) - .map { NotificationMapper.toDomain(it) } + + ): Result = withContext(Dispatchers.Default) { + // 1. 두 개의 작업을 'async'로 동시에 시작 + val totalResultAsync = async { + alarmRemoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNKNOWN) + } + val unreadResultAsync = async { + alarmRemoteDataSource.getNotificationCount(status = NotificationStatusEnum.UNREAD) + } + + // 2. 두 작업의 결과를 'await'로 수집 + val totalResult = totalResultAsync.await() + val unreadResult = unreadResultAsync.await() + + // 3. 두 Result를 'runCatching'으로 안전하게 조합 + runCatching { + val totalDto = totalResult.getOrThrow() // totalResult가 Failure라면 여기서 예외가 발생 + + val unreadDto = unreadResult.getOrThrow() // unreadResult가 Failure라면 여기서 예외가 발생 + + // 두 DTO를 성공적으로 가져온 경우에만 실행됨 + NotificationCount( + totalCount = totalDto.count, + unreadCount = unreadDto.count, + readCount = totalDto.count - unreadDto.count, + ) + } // runCatching 블록이 발생한 예외를 잡아 Result.failure로 반환해줌 } override suspend fun markNotificationsAsRead( diff --git a/app/src/main/java/com/autoever/everp/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..34c076a --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.autoever.everp.data.repository + +import com.autoever.everp.data.datasource.local.AuthLocalDataSource +import com.autoever.everp.data.datasource.remote.AuthRemoteDataSource +import com.autoever.everp.domain.repository.AuthRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authRemoteDataSource: AuthRemoteDataSource, + private val authLocalDataSource: AuthLocalDataSource, +) : AuthRepository { + + override val accessTokenFlow: Flow + get() = authLocalDataSource.accessTokenFlow + + override suspend fun loginWithAuthorizationCode( + clientId: String, + redirectUri: String, + code: String, + codeVerifier: String, + ): Result { + return authRemoteDataSource.exchangeAuthCodeForToken( + clientId = clientId, + redirectUri = redirectUri, + code = code, + codeVerifier = codeVerifier, + ).mapCatching { + authLocalDataSource.saveAccessToken(it) + } + } + + override suspend fun getAccessTokenWithType(): String? { + // AuthLocalDataSource에 helper가 없으면 로컬에서 직접 조합 + val token = authLocalDataSource.getAccessToken() ?: return null + // 기본 타입은 Bearer로 가정, 저장된 타입이 있다면 사용하도록 확장 가능 + return "Bearer $token" + } + + override suspend fun logout(): Result { + val bearer = getAccessTokenWithType() ?: return Result.success(Unit).also { + authLocalDataSource.clearAccessToken() + } + return authRemoteDataSource.logout(bearer).onSuccess { + authLocalDataSource.clearAccessToken() + } + } +} diff --git a/app/src/main/java/com/autoever/everp/data/repository/DashboardRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/DashboardRepositoryImpl.kt new file mode 100644 index 0000000..288a5e4 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/repository/DashboardRepositoryImpl.kt @@ -0,0 +1,33 @@ +package com.autoever.everp.data.repository + +import com.autoever.everp.data.datasource.local.DashboardLocalDataSource +import com.autoever.everp.data.datasource.remote.DashboardRemoteDataSource +import com.autoever.everp.data.datasource.remote.mapper.DashboardMapper +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +import com.autoever.everp.domain.model.user.UserRoleEnum +import com.autoever.everp.domain.repository.DashboardRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DashboardRepositoryImpl @Inject constructor( + private val dashboardLocalDataSource: DashboardLocalDataSource, + private val dashboardRemoteDataSource: DashboardRemoteDataSource, +) : DashboardRepository { + + override fun observeWorkflows(): Flow = + dashboardLocalDataSource.observeWorkflows() + + override suspend fun refreshWorkflows(role: UserRoleEnum): Result = withContext(Dispatchers.Default) { + getWorkflows(role).map { workflows -> + dashboardLocalDataSource.setWorkflows(workflows) + } + } + + override suspend fun getWorkflows(role: UserRoleEnum): Result = withContext(Dispatchers.Default) { + dashboardRemoteDataSource.getDashboardWorkflows(role) + .map { DashboardMapper.toDomain(it) } + } +} + diff --git a/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt index 9d2bc9a..2796bcc 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/FcmRepositoryImpl.kt @@ -9,7 +9,9 @@ import com.autoever.everp.domain.model.invoice.InvoiceDetail import com.autoever.everp.domain.model.invoice.InvoiceListItem import com.autoever.everp.domain.model.invoice.InvoiceListParams import com.autoever.everp.domain.repository.FcmRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -25,16 +27,18 @@ class FcmRepositoryImpl @Inject constructor( override fun observeApInvoiceList(): Flow> = fcmFinanceLocalDataSource.observeApInvoiceList() - override suspend fun refreshApInvoiceList(params: InvoiceListParams): Result { - return getApInvoiceList(params).map { page -> + override suspend fun refreshApInvoiceList( + params: InvoiceListParams, + ): Result = withContext(Dispatchers.Default) { + getApInvoiceList(params).map { page -> fcmFinanceLocalDataSource.setApInvoiceList(page) } } override suspend fun getApInvoiceList( params: InvoiceListParams, - ): Result> { - return fcmFinanceRemoteDataSource.getApInvoiceList( + ): Result> = withContext(Dispatchers.Default) { + fcmFinanceRemoteDataSource.getApInvoiceList( // company = params.company, startDate = params.startDate, endDate = params.endDate, @@ -81,16 +85,18 @@ class FcmRepositoryImpl @Inject constructor( override fun observeArInvoiceList(): Flow> = fcmFinanceLocalDataSource.observeArInvoiceList() - override suspend fun refreshArInvoiceList(params: InvoiceListParams): Result { - return getArInvoiceList(params).map { page -> + override suspend fun refreshArInvoiceList( + params: InvoiceListParams, + ): Result = withContext(Dispatchers.Default) { + getArInvoiceList(params).map { page -> fcmFinanceLocalDataSource.setArInvoiceList(page) } } override suspend fun getArInvoiceList( params: InvoiceListParams, - ): Result> { - return fcmFinanceRemoteDataSource.getArInvoiceList( + ): Result> = withContext(Dispatchers.Default) { + fcmFinanceRemoteDataSource.getArInvoiceList( // companyName = params.company, startDate = params.startDate, endDate = params.endDate, @@ -129,12 +135,12 @@ class FcmRepositoryImpl @Inject constructor( } } -// override suspend fun completeReceivable(invoiceId: String): Result { -// return fcmFinanceRemoteDataSource.completeReceivable(invoiceId) -// .onSuccess { -// // 완료 성공 시 로컬 캐시 갱신 -// refreshArInvoiceDetail(invoiceId) -// } -// } + override suspend fun completeReceivable(invoiceId: String): Result { + return fcmFinanceRemoteDataSource.completeReceivable(invoiceId) + .onSuccess { + // 완료 성공 시 로컬 캐시 갱신 + refreshArInvoiceDetail(invoiceId) + } + } } diff --git a/app/src/main/java/com/autoever/everp/data/repository/ImRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/ImRepositoryImpl.kt new file mode 100644 index 0000000..304dab3 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/repository/ImRepositoryImpl.kt @@ -0,0 +1,31 @@ +package com.autoever.everp.data.repository + +import com.autoever.everp.data.datasource.local.ImLocalDataSource +import com.autoever.everp.data.datasource.remote.ImRemoteDataSource +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import com.autoever.everp.domain.repository.ImRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class ImRepositoryImpl @Inject constructor( + private val imLocalDataSource: ImLocalDataSource, + private val imRemoteDataSource: ImRemoteDataSource, +) : ImRepository { + + override fun observeItemToggleList(): Flow> = + imLocalDataSource.observeItemToggleList() + + override suspend fun refreshItemToggleList( + + ): Result = withContext(Dispatchers.Default) { + getItemToggleList().map { list -> + imLocalDataSource.setItemToggleList(list) + } + } + + override suspend fun getItemToggleList(): Result> { + return imRemoteDataSource.getItemsToggle() + } +} diff --git a/app/src/main/java/com/autoever/everp/data/repository/MmRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/MmRepositoryImpl.kt index 14934d2..b869c3b 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/MmRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/MmRepositoryImpl.kt @@ -11,7 +11,9 @@ import com.autoever.everp.domain.model.purchase.PurchaseOrderListItem import com.autoever.everp.domain.model.purchase.PurchaseOrderListParams import com.autoever.everp.domain.model.supplier.SupplierDetail import com.autoever.everp.domain.repository.MmRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -55,16 +57,16 @@ class MmRepositoryImpl @Inject constructor( override suspend fun refreshPurchaseOrderList( params: PurchaseOrderListParams, - ): Result { - return getPurchaseOrderList(params).map { page -> + ): Result = withContext(Dispatchers.Default) { + getPurchaseOrderList(params).map { page -> mmLocalDataSource.setPurchaseOrderList(page) } } override suspend fun getPurchaseOrderList( params: PurchaseOrderListParams, - ): Result> { - return mmRemoteDataSource.getPurchaseOrderList( + ): Result> = withContext(Dispatchers.Default) { + mmRemoteDataSource.getPurchaseOrderList( statusCode = params.statusCode, type = params.type, keyword = params.keyword, diff --git a/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt new file mode 100644 index 0000000..512faba --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt @@ -0,0 +1,31 @@ +package com.autoever.everp.data.repository + +import com.autoever.everp.data.datasource.local.ProfileLocalDataSource +import com.autoever.everp.data.datasource.remote.ProfileRemoteDataSource +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserTypeEnum +import com.autoever.everp.domain.repository.ProfileRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class ProfileRepositoryImpl @Inject constructor( + private val profileLocalDataSource: ProfileLocalDataSource, + private val profileRemoteDataSource: ProfileRemoteDataSource, +) : ProfileRepository { + + override fun observeProfile(): Flow = + profileLocalDataSource.observeProfile() + + override suspend fun refreshProfile(userType: UserTypeEnum): Result = withContext(Dispatchers.Default) { + getProfile(userType).map { profile -> + profileLocalDataSource.setProfile(profile) + } + } + + override suspend fun getProfile(userType: UserTypeEnum): Result = withContext(Dispatchers.Default) { + profileRemoteDataSource.getProfile(userType) + } +} + diff --git a/app/src/main/java/com/autoever/everp/data/repository/SdRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/SdRepositoryImpl.kt index 517891c..89c1780 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/SdRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/SdRepositoryImpl.kt @@ -13,7 +13,9 @@ import com.autoever.everp.domain.model.sale.SalesOrderDetail import com.autoever.everp.domain.model.sale.SalesOrderListItem import com.autoever.everp.domain.model.sale.SalesOrderListParams import com.autoever.everp.domain.repository.SdRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -29,16 +31,18 @@ class SdRepositoryImpl @Inject constructor( override fun observeQuotationList(): Flow> = sdLocalDataSource.observeQuotationList() - override suspend fun refreshQuotationList(params: QuotationListParams): Result { - return getQuotationList(params).map { page -> + override suspend fun refreshQuotationList( + params: QuotationListParams, + ): Result = withContext(Dispatchers.Default) { + getQuotationList(params).map { page -> sdLocalDataSource.setQuotationList(page) } } override suspend fun getQuotationList( params: QuotationListParams, - ): Result> { - return sdRemoteDataSource.getQuotationList( + ): Result> = withContext(Dispatchers.Default) { + sdRemoteDataSource.getQuotationList( startDate = params.startDate, endDate = params.endDate, status = params.status, @@ -91,20 +95,33 @@ class SdRepositoryImpl @Inject constructor( .map { SdMapper.customerDetailToDomain(it) } } + override suspend fun updateCustomer( + customerId: String, + request: com.autoever.everp.data.datasource.remote.http.service.CustomerUpdateRequestDto, + ): Result { + return sdRemoteDataSource.updateCustomer(customerId, request) + .onSuccess { + // 수정 성공 시 로컬 캐시 갱신 + refreshCustomerDetail(customerId) + } + } + // ========== 주문서 ========== override fun observeSalesOrderList(): Flow> = sdLocalDataSource.observeSalesOrderList() - override suspend fun refreshSalesOrderList(params: SalesOrderListParams): Result { - return getSalesOrderList(params).map { page -> + override suspend fun refreshSalesOrderList( + params: SalesOrderListParams + ): Result = withContext(Dispatchers.Default) { + getSalesOrderList(params).map { page -> sdLocalDataSource.setSalesOrderList(page) } } override suspend fun getSalesOrderList( params: SalesOrderListParams, - ): Result> { - return sdRemoteDataSource.getSalesOrderList( + ): Result> = withContext(Dispatchers.Default) { + sdRemoteDataSource.getSalesOrderList( startDate = params.startDate, endDate = params.endDate, search = params.searchKeyword, diff --git a/app/src/main/java/com/autoever/everp/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/UserRepositoryImpl.kt index eaf3084..9dd3a38 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/UserRepositoryImpl.kt @@ -1,5 +1,6 @@ package com.autoever.everp.data.repository +import com.autoever.everp.data.datasource.local.AuthLocalDataSource import com.autoever.everp.data.datasource.local.UserLocalDataSource import com.autoever.everp.data.datasource.remote.UserRemoteDataSource import com.autoever.everp.data.datasource.remote.mapper.UserMapper @@ -15,6 +16,7 @@ import javax.inject.Inject class UserRepositoryImpl @Inject constructor( private val userLocalDataSource: UserLocalDataSource, private val userRemoteDataSource: UserRemoteDataSource, + private val authLocalDataSource: AuthLocalDataSource ) : UserRepository { override fun observeUserInfo(): Flow = @@ -33,5 +35,6 @@ class UserRepositoryImpl @Inject constructor( override suspend fun logout() { userLocalDataSource.clearUserInfo() + authLocalDataSource.clearAccessToken() } } diff --git a/app/src/main/java/com/autoever/everp/di/AppModule.kt b/app/src/main/java/com/autoever/everp/di/AppModule.kt index 89c43fd..dc5a6da 100644 --- a/app/src/main/java/com/autoever/everp/di/AppModule.kt +++ b/app/src/main/java/com/autoever/everp/di/AppModule.kt @@ -6,11 +6,15 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.autoever.everp.auth.session.TokenStore import com.autoever.everp.auth.session.TokenStoreImpl +import com.autoever.everp.common.annotation.ApplicationScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -37,4 +41,11 @@ object AppModule { @Provides @Singleton fun provideTokenStore(prefs: SharedPreferences): TokenStore = TokenStoreImpl(prefs) + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } } diff --git a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt index 7c5b7e4..4b9866f 100644 --- a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt +++ b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt @@ -1,23 +1,33 @@ package com.autoever.everp.di import com.autoever.everp.data.datasource.local.AlarmLocalDataSource +import com.autoever.everp.data.datasource.local.AuthLocalDataSource import com.autoever.everp.data.datasource.local.FcmLocalDataSource import com.autoever.everp.data.datasource.local.MmLocalDataSource import com.autoever.everp.data.datasource.local.SdLocalDataSource +import com.autoever.everp.data.datasource.local.TokenLocalDataSource import com.autoever.everp.data.datasource.local.UserLocalDataSource +import com.autoever.everp.data.datasource.local.datastore.AuthDataStoreLocalDataSourceImpl +import com.autoever.everp.data.datasource.local.datastore.TokenDataStoreLocalDataSourceImpl import com.autoever.everp.data.datasource.local.impl.AlarmLocalDataSourceImpl import com.autoever.everp.data.datasource.local.impl.FcmLocalDataSourceImpl import com.autoever.everp.data.datasource.local.impl.MmLocalDataSourceImpl +import com.autoever.everp.data.datasource.local.ImLocalDataSource +import com.autoever.everp.data.datasource.local.impl.ImLocalDataSourceImpl import com.autoever.everp.data.datasource.local.impl.SdLocalDataSourceImpl import com.autoever.everp.data.datasource.local.impl.UserLocalDataSourceImpl import com.autoever.everp.data.datasource.remote.AlarmRemoteDataSource +import com.autoever.everp.data.datasource.remote.AuthRemoteDataSource import com.autoever.everp.data.datasource.remote.FcmRemoteDataSource import com.autoever.everp.data.datasource.remote.MmRemoteDataSource import com.autoever.everp.data.datasource.remote.SdRemoteDataSource import com.autoever.everp.data.datasource.remote.UserRemoteDataSource import com.autoever.everp.data.datasource.remote.http.impl.AlarmHttpRemoteDataSourceImpl +import com.autoever.everp.data.datasource.remote.http.impl.AuthHttpRemoteDataSourceImpl import com.autoever.everp.data.datasource.remote.http.impl.FcmHttpRemoteDataSourceImpl import com.autoever.everp.data.datasource.remote.http.impl.MmHttpRemoteDataSourceImpl +import com.autoever.everp.data.datasource.remote.ImRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.impl.ImHttpRemoteDataSourceImpl import com.autoever.everp.data.datasource.remote.http.impl.SdHttpRemoteDataSourceImpl import com.autoever.everp.data.datasource.remote.http.impl.UserHttpRemoteDataSourceImpl import dagger.Binds @@ -94,4 +104,63 @@ abstract class DataSourceModule { abstract fun bindsSdLocalDataSource( sdLocalDataSourceImpl: SdLocalDataSourceImpl, ): SdLocalDataSource + + // Im Data Sources + @Binds + @Singleton + abstract fun bindsImRemoteDataSource( + imHttpRemoteDataSourceImpl: ImHttpRemoteDataSourceImpl, + ): ImRemoteDataSource + + @Binds + @Singleton + abstract fun bindsImLocalDataSource( + imLocalDataSourceImpl: ImLocalDataSourceImpl, + ): ImLocalDataSource + + // Auth Data Sources + @Binds + @Singleton + abstract fun bindsAuthRemoteDataSource( + authHttpRemoteDataSourceImpl: AuthHttpRemoteDataSourceImpl + ): AuthRemoteDataSource + + @Binds + @Singleton + abstract fun bindsAuthLocalDataSource( + authDataStoreLocalDataSourceImpl: AuthDataStoreLocalDataSourceImpl + ): AuthLocalDataSource + + // Token Data Sources (AccessToken, FcmToken) + @Binds + @Singleton + abstract fun bindsTokenLocalDataSource( + tokenDataStoreLocalDataSourceImpl: TokenDataStoreLocalDataSourceImpl + ): TokenLocalDataSource + + // Dashboard Data Sources + @Binds + @Singleton + abstract fun bindsDashboardRemoteDataSource( + dashboardHttpRemoteDataSourceImpl: com.autoever.everp.data.datasource.remote.http.impl.DashboardHttpRemoteDataSourceImpl, + ): com.autoever.everp.data.datasource.remote.DashboardRemoteDataSource + + @Binds + @Singleton + abstract fun bindsDashboardLocalDataSource( + dashboardLocalDataSourceImpl: com.autoever.everp.data.datasource.local.impl.DashboardLocalDataSourceImpl, + ): com.autoever.everp.data.datasource.local.DashboardLocalDataSource + + // Profile Data Sources + @Binds + @Singleton + abstract fun bindsProfileRemoteDataSource( + profileHttpRemoteDataSourceImpl: com.autoever.everp.data.datasource.remote.http.impl.ProfileHttpRemoteDataSourceImpl, + ): com.autoever.everp.data.datasource.remote.ProfileRemoteDataSource + + @Binds + @Singleton + abstract fun bindsProfileLocalDataSource( + profileLocalDataSourceImpl: com.autoever.everp.data.datasource.local.impl.ProfileLocalDataSourceImpl, + ): com.autoever.everp.data.datasource.local.ProfileLocalDataSource } diff --git a/app/src/main/java/com/autoever/everp/di/NetworkModule.kt b/app/src/main/java/com/autoever/everp/di/NetworkModule.kt index 7f026b1..8d04981 100644 --- a/app/src/main/java/com/autoever/everp/di/NetworkModule.kt +++ b/app/src/main/java/com/autoever/everp/di/NetworkModule.kt @@ -1,8 +1,11 @@ package com.autoever.everp.di import com.autoever.everp.BuildConfig +import com.autoever.everp.common.annotation.AuthRetrofit +import com.autoever.everp.common.annotation.NormalRetrofit import com.autoever.everp.data.datasource.remote.http.service.AlarmApi import com.autoever.everp.data.datasource.remote.http.service.AlarmTokenApi +import com.autoever.everp.data.datasource.remote.http.service.AuthApi import com.autoever.everp.data.datasource.remote.http.service.FcmApi import com.autoever.everp.data.datasource.remote.http.service.HrmApi import com.autoever.everp.data.datasource.remote.http.service.ImApi @@ -71,6 +74,32 @@ object NetworkModule { @Provides @Singleton + @AuthRetrofit + fun provideAuthRetrofit( + json: Json, + loggingInterceptor: HttpLoggingInterceptor, + ): Retrofit { + // 인증 전용 Retrofit (Auth 서버용) + // TODO : 공통 OkHttpClient 사용하도록 변경 검토 + // TODO : 미완성 - 타임아웃 등 설정 추가 필요 + val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .apply { + connectTimeout(30, TimeUnit.SECONDS) + readTimeout(30, TimeUnit.SECONDS) + writeTimeout(30, TimeUnit.SECONDS) + }.build() + + return Retrofit.Builder() + .baseUrl(BuildConfig.AUTH_BASE_URL) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @Provides + @Singleton + @NormalRetrofit fun provideRetrofit( okHttpClient: OkHttpClient, json: Json, @@ -89,41 +118,56 @@ object NetworkModule { @Provides @Singleton - fun provideAlarmApiService(retrofit: Retrofit): AlarmApi = + fun provideAlarmApiService(@NormalRetrofit retrofit: Retrofit): AlarmApi = retrofit.create(AlarmApi::class.java) @Provides @Singleton - fun provideFcmTokenApiService(retrofit: Retrofit): AlarmTokenApi = + fun provideFcmTokenApiService(@NormalRetrofit retrofit: Retrofit): AlarmTokenApi = retrofit.create(AlarmTokenApi::class.java) @Provides @Singleton - fun provideSdApiService(retrofit: Retrofit): SdApi = + fun provideSdApiService(@NormalRetrofit retrofit: Retrofit): SdApi = retrofit.create(SdApi::class.java) @Provides @Singleton - fun provideHrmApiService(retrofit: Retrofit): HrmApi = + fun provideHrmApiService(@NormalRetrofit retrofit: Retrofit): HrmApi = retrofit.create(HrmApi::class.java) @Provides @Singleton - fun provideFcmFinanceApiService(retrofit: Retrofit): FcmApi = + fun provideFcmFinanceApiService(@NormalRetrofit retrofit: Retrofit): FcmApi = retrofit.create(FcmApi::class.java) @Provides @Singleton - fun provideInventoryApiService(retrofit: Retrofit): ImApi = + fun provideInventoryApiService(@NormalRetrofit retrofit: Retrofit): ImApi = retrofit.create(ImApi::class.java) @Provides @Singleton - fun provideMaterialApiService(retrofit: Retrofit): MmApi = + fun provideMaterialApiService(@NormalRetrofit retrofit: Retrofit): MmApi = retrofit.create(MmApi::class.java) @Provides @Singleton - fun provideUserApiService(retrofit: Retrofit): UserApi = + fun provideUserApiService(@NormalRetrofit retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java) + + @Provides + @Singleton + fun provideUserAuthApiService(@AuthRetrofit retrofit: Retrofit): AuthApi = + retrofit.create(AuthApi::class.java) + + @Provides + @Singleton + fun provideDashboardApiService(@NormalRetrofit retrofit: Retrofit): com.autoever.everp.data.datasource.remote.http.service.DashboardApi = + retrofit.create(com.autoever.everp.data.datasource.remote.http.service.DashboardApi::class.java) + + @Provides + @Singleton + fun provideProfileApiService(@NormalRetrofit retrofit: Retrofit): com.autoever.everp.data.datasource.remote.http.service.ProfileApi = + retrofit.create(com.autoever.everp.data.datasource.remote.http.service.ProfileApi::class.java) } diff --git a/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt b/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt index 4ef73ac..d220ae8 100644 --- a/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt +++ b/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt @@ -1,16 +1,20 @@ package com.autoever.everp.di import com.autoever.everp.data.repository.AlarmRepositoryImpl +import com.autoever.everp.data.repository.AuthRepositoryImpl import com.autoever.everp.data.repository.DeviceInfoRepositoryImpl import com.autoever.everp.data.repository.FcmRepositoryImpl import com.autoever.everp.data.repository.MmRepositoryImpl +import com.autoever.everp.data.repository.ImRepositoryImpl import com.autoever.everp.data.repository.PushNotificationRepositoryImpl import com.autoever.everp.data.repository.SdRepositoryImpl import com.autoever.everp.data.repository.UserRepositoryImpl import com.autoever.everp.domain.repository.AlarmRepository +import com.autoever.everp.domain.repository.AuthRepository import com.autoever.everp.domain.repository.DeviceInfoRepository import com.autoever.everp.domain.repository.FcmRepository import com.autoever.everp.domain.repository.MmRepository +import com.autoever.everp.domain.repository.ImRepository import com.autoever.everp.domain.repository.PushNotificationRepository import com.autoever.everp.domain.repository.SdRepository import com.autoever.everp.domain.repository.UserRepository @@ -59,9 +63,33 @@ abstract class RepositoryModule { mmRepositoryImpl: MmRepositoryImpl, ): MmRepository + @Binds + @Singleton + abstract fun bindsImRepository( + imRepositoryImpl: ImRepositoryImpl, + ): ImRepository + @Binds @Singleton abstract fun bindsUserRepository( userRepositoryImpl: UserRepositoryImpl, ): UserRepository + + @Binds + @Singleton + abstract fun bindsAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository + + @Binds + @Singleton + abstract fun bindsDashboardRepository( + dashboardRepositoryImpl: com.autoever.everp.data.repository.DashboardRepositoryImpl, + ): com.autoever.everp.domain.repository.DashboardRepository + + @Binds + @Singleton + abstract fun bindsProfileRepository( + profileRepositoryImpl: com.autoever.everp.data.repository.ProfileRepositoryImpl, + ): com.autoever.everp.domain.repository.ProfileRepository } diff --git a/app/src/main/java/com/autoever/everp/domain/model/auth/AccessToken.kt b/app/src/main/java/com/autoever/everp/domain/model/auth/AccessToken.kt new file mode 100644 index 0000000..0104a5f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/auth/AccessToken.kt @@ -0,0 +1,9 @@ +package com.autoever.everp.domain.model.auth + +import java.time.LocalDateTime + +data class AccessToken( + val token: String, + val expiresIn: LocalDateTime, + val type: String = "Bearer" +) diff --git a/app/src/main/java/com/autoever/everp/domain/model/common/PagingData.kt b/app/src/main/java/com/autoever/everp/domain/model/common/PagingData.kt new file mode 100644 index 0000000..0ad91f3 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/common/PagingData.kt @@ -0,0 +1,31 @@ +package com.autoever.everp.domain.model.common + +data class PagingData( + val items: List = emptyList(), + val totalItems: Int = 0, + val totalPages: Int = 0, + val currentPage: Int = 0, + val size: Int = 20, + val hasNext: Boolean = false, +) { + + val hasPrevious: Boolean + get() = currentPage > 0 + + val isFirst: Boolean + get() = currentPage == 0 + + val isLast: Boolean + get() = !hasNext + + companion object { + fun empty(): PagingData = PagingData( + items = emptyList(), + totalItems = 0, + totalPages = 0, + currentPage = 0, + size = 20, + hasNext = false, + ) + } +} diff --git a/app/src/main/java/com/autoever/everp/domain/model/common/PagingDataMapper.kt b/app/src/main/java/com/autoever/everp/domain/model/common/PagingDataMapper.kt new file mode 100644 index 0000000..c2f579f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/common/PagingDataMapper.kt @@ -0,0 +1,43 @@ +package com.autoever.everp.domain.model.common + +import com.autoever.everp.data.datasource.remote.dto.common.PageDto +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse + +/** + * PageResponse(DTO) -> PageData(Domain) 변환 매퍼 + */ + +/** + * PageResponse를 PagingData 변환 + * @param transform content의 각 아이템을 Domain 모델로 변환하는 함수 + */ +fun PageResponse.toDomain(transform: (T) -> R): PagingData { + return PagingData( + items = content.map(transform), + totalItems = page.totalElements, + totalPages = page.totalPages, + currentPage = page.number, + size = page.size, + hasNext = page.hasNext, + ) +} + +/** + * PageResponse를 PagingData 변환 (content가 이미 Domain 모델인 경우) + */ +fun PageResponse.toDomain(): PagingData { + return PagingData( + items = content, + totalItems = page.totalElements, + totalPages = page.totalPages, + currentPage = page.number, + size = page.size, + hasNext = page.hasNext, + ) +} + +/** + * 빈 PagingData 생성 + */ +fun emptyPageData(): PagingData = PagingData.empty() + diff --git a/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt new file mode 100644 index 0000000..de97391 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt @@ -0,0 +1,17 @@ +package com.autoever.everp.domain.model.dashboard + +data class DashboardWorkflows( + val tabs: List, +) { + data class DashboardWorkflowTab( + val tabCode: DashboardTapEnum, + val id: String, + val number: String, + val description: String, + val createdBy: String, + val status: String, +// val createdAt: LocalDate, + val createdAt: String, + ) +} + diff --git a/app/src/main/java/com/autoever/everp/domain/model/dashboard/DashboardTapEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/DashboardTapEnum.kt new file mode 100644 index 0000000..667ee17 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/dashboard/DashboardTapEnum.kt @@ -0,0 +1,254 @@ +package com.autoever.everp.domain.model.dashboard + +import androidx.compose.ui.graphics.Color +import com.autoever.everp.ui.customer.CustomerSubNavigationItem + +enum class DashboardTapEnum { + UNKNOWN, // 알 수 없음, 기본값 + PO, // 발주, Purchase Order + AP, // 매입, Accounts Payable + AR, // 매출, Accounts Receivable + SO, // 주문, Sales Order + PR, // 구매, Purchase Requisition + ATT, // 근태, Attendance + LV, // 휴가, Leave + QT, // 견적, Quotation + MES, // 생산, Manufacturing Execution System + ; + + /** + * UI 표시용 한글명 + */ + fun toKorean(): String = + when (this) { + UNKNOWN -> "알 수 없음" + PO -> "발주" + AP -> "매입" + AR -> "매출" + SO -> "주문" + PR -> "구매" + ATT -> "근태" + LV -> "휴가" + QT -> "견적" + MES -> "생산" + } + + /** + * 표시 이름 + */ + fun displayName(): String = toKorean() + + /** + * API 통신용 문자열 (대문자) + */ + fun toApiString(): String? = if (this != UNKNOWN) this.name else null + + /** + * 탭 설명 + */ + fun description(): String = + when (this) { + UNKNOWN -> "알 수 없는 탭" + PO -> "발주서 관리 및 조회" + AP -> "매입 전표 관리 및 조회" + AR -> "매출 전표 관리 및 조회" + SO -> "주문서 관리 및 조회" + PR -> "구매 요청서 관리 및 조회" + ATT -> "근태 관리 및 조회" + LV -> "휴가 신청 및 관리" + QT -> "견적서 관리 및 조회" + MES -> "생산 계획 및 실행 관리" + } + + /** + * 전체 이름 (영문) + */ + fun fullName(): String = + when (this) { + UNKNOWN -> "Unknown" + PO -> "Purchase Order" + AP -> "Accounts Payable" + AR -> "Accounts Receivable" + SO -> "Sales Order" + PR -> "Purchase Requisition" + ATT -> "Attendance" + LV -> "Leave" + QT -> "Quotation" + MES -> "Manufacturing Execution System" + } + + /** + * UI 색상 (Compose Color) + * TODO: 실제 디자인 시스템 색상으로 교체 + */ + fun toColor(): Color = + when (this) { + UNKNOWN -> Color(0xFF9E9E9E) // Grey + PO -> Color(0xFF9C27B0) // Purple + AP -> Color(0xFFF44336) // Red + AR -> Color(0xFF4CAF50) // Green + SO -> Color(0xFF2196F3) // Blue + PR -> Color(0xFFFF9800) // Orange + ATT -> Color(0xFF00BCD4) // Cyan + LV -> Color(0xFFFFEB3B) // Yellow + QT -> Color(0xFF3F51B5) // Indigo + MES -> Color(0xFF795548) // Brown + } + + /** + * 탭 코드 값 + */ + val code: String get() = this.name + + /** + * 유효한 탭인지 확인 (UNKNOWN 제외) + */ + fun isValid(): Boolean = this != UNKNOWN + + /** + * 구매 관련 탭인지 확인 + */ + fun isPurchaseRelated(): Boolean = this == PO || this == PR + + /** + * 영업 관련 탭인지 확인 + */ + fun isSalesRelated(): Boolean = this == SO || this == QT || this == AR + + /** + * 재무 관련 탭인지 확인 + */ + fun isFinanceRelated(): Boolean = this == AP || this == AR + + /** + * 인사 관련 탭인지 확인 + */ + fun isHrRelated(): Boolean = this == ATT || this == LV + + /** + * 생산 관련 탭인지 확인 + */ + fun isProductionRelated(): Boolean = this == MES + + /** + * 카테고리 반환 + */ + fun getCategory(): String = + when { + isPurchaseRelated() -> "구매" + isSalesRelated() -> "영업" + isFinanceRelated() -> "재무" + isHrRelated() -> "인사" + isProductionRelated() -> "생산" + else -> "기타" + } + + /** + * 관련 NotificationLinkEnum 반환 + */ + fun toNotificationLink(): com.autoever.everp.domain.model.notification.NotificationLinkEnum? = + when (this) { + QT -> com.autoever.everp.domain.model.notification.NotificationLinkEnum.QUOTATION + SO -> com.autoever.everp.domain.model.notification.NotificationLinkEnum.SALES_ORDER + AP -> com.autoever.everp.domain.model.notification.NotificationLinkEnum.PURCHASE_INVOICE + AR -> com.autoever.everp.domain.model.notification.NotificationLinkEnum.SALES_INVOICE + else -> null + } + + fun isCustomerRelated(): Boolean { + return this == AR || this == SO || this == QT + } + + fun isSupplierRelated(): Boolean { + return this == AP || this == PO + } + + companion object { + /** + * 문자열을 DashboardTapEnum으로 변환 + * @throws IllegalArgumentException + */ + fun fromString(value: String): DashboardTapEnum = + try { + valueOf(value.uppercase()) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException( + "Invalid DashboardTapEnum: '$value'. " + + "Valid values are: ${entries.joinToString { it.name }}", + ) + } + + /** + * 문자열을 DashboardTapEnum으로 안전하게 변환 (null 반환) + */ + fun fromStringOrNull(value: String): DashboardTapEnum? = + try { + valueOf(value.uppercase()) + } catch (e: IllegalArgumentException) { + null + } + + /** + * 문자열을 DashboardTapEnum으로 변환 (기본값 제공) + */ + fun fromStringOrDefault( + value: String, + default: DashboardTapEnum = UNKNOWN, + ): DashboardTapEnum = fromStringOrNull(value) ?: default + + /** + * 모든 enum 값을 문자열 리스트로 반환 + */ + fun getAllValues(): List = entries.map { it.name } + + /** + * 유효한 탭 목록 (UNKNOWN 제외) + */ + fun getValidTaps(): List = + entries.filter { it != UNKNOWN } + + /** + * 구매 관련 탭 목록 + */ + fun getPurchaseTaps(): List = + entries.filter { it.isPurchaseRelated() } + + /** + * 영업 관련 탭 목록 + */ + fun getSalesTaps(): List = + entries.filter { it.isSalesRelated() } + + /** + * 재무 관련 탭 목록 + */ + fun getFinanceTaps(): List = + entries.filter { it.isFinanceRelated() } + + /** + * 인사 관련 탭 목록 + */ + fun getHrTaps(): List = + entries.filter { it.isHrRelated() } + + /** + * 생산 관련 탭 목록 + */ + fun getProductionTaps(): List = + entries.filter { it.isProductionRelated() } + + /** + * 카테고리별 탭 그룹핑 + */ + fun getTapsByCategory(): Map> = + getValidTaps().groupBy { it.getCategory() } + + fun isCustomerRelated(tap: DashboardTapEnum): Boolean { + return tap == AR || tap == SO || tap == QT + } + + fun isSupplierRelated(tap: DashboardTapEnum): Boolean { + return tap == AP || tap == PO + } + } +} diff --git a/app/src/main/java/com/autoever/everp/domain/model/inventory/InventoryItemToggle.kt b/app/src/main/java/com/autoever/everp/domain/model/inventory/InventoryItemToggle.kt new file mode 100644 index 0000000..8438ac8 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/inventory/InventoryItemToggle.kt @@ -0,0 +1,15 @@ +package com.autoever.everp.domain.model.inventory + +/** + * 재고 아이템(토글용) Domain Model + */ +data class InventoryItemToggle( + val itemId: String, + val itemName: String, + val uomName: String, + val unitPrice: Long, +// val supplierCompanyId: String, +// val supplierCompanyName: String, +) + + diff --git a/app/src/main/java/com/autoever/everp/domain/model/notification/NotificationLinkEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/notification/NotificationLinkEnum.kt index 010fa74..3c0f6d1 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/notification/NotificationLinkEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/notification/NotificationLinkEnum.kt @@ -180,6 +180,21 @@ enum class NotificationLinkEnum { */ val code: String get() = this.name + fun isCustomerRelated(): Boolean = + when (this) { + QUOTATION, + SALES_ORDER, + SALES_INVOICE -> true + else -> false + } + + fun isSupplierRelated(): Boolean = + when (this) { + PURCHASE_ORDER, + PURCHASE_INVOICE -> true + else -> false + } + companion object { /** * 문자열을 NotificationLinkEnum으로 변환 diff --git a/app/src/main/java/com/autoever/everp/domain/model/profile/Profile.kt b/app/src/main/java/com/autoever/everp/domain/model/profile/Profile.kt new file mode 100644 index 0000000..e3a95ae --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/profile/Profile.kt @@ -0,0 +1,20 @@ +package com.autoever.everp.domain.model.profile + +data class Profile( + val userName: String, + val userEmail: String, + val userPhoneNumber: String, + val companyName: String, + val businessNumber: String, + val baseAddress: String, + val detailAddress: String, + val officePhone: String, +) { + val fullAddress: String + get() = if (detailAddress.isNotBlank()) { + "$baseAddress $detailAddress" + } else { + baseAddress + } +} + diff --git a/app/src/main/java/com/autoever/everp/domain/model/purchase/PurchaseOrderStatusEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/purchase/PurchaseOrderStatusEnum.kt index 53132cf..17393cb 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/purchase/PurchaseOrderStatusEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/purchase/PurchaseOrderStatusEnum.kt @@ -7,8 +7,8 @@ enum class PurchaseOrderStatusEnum { APPROVAL, // 승인 PENDING, // 대기 REJECTED, // 반려 - DELIVERING, // 배송중 - DELIVERED, // 배송완료 + DELIVERING, // 배송중 -> 사용 안 함 + DELIVERED, // 배송완료 -> 사용 안 함 ; /** diff --git a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationCreateRequest.kt b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationCreateRequest.kt index 8335628..dd03043 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationCreateRequest.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationCreateRequest.kt @@ -3,7 +3,7 @@ package com.autoever.everp.domain.model.quotation import java.time.LocalDate data class QuotationCreateRequest( - val dueDate: LocalDate, + val dueDate: LocalDate? = null, val items: List, val note: String = "", ) { diff --git a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationListItem.kt b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationListItem.kt index f011c18..0f5756a 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationListItem.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationListItem.kt @@ -7,7 +7,7 @@ data class QuotationListItem( val number: String, // 견적서 코드 val customer: QuotationListItemCustomer, val status: QuotationStatusEnum, // 상태 값은 Enum으로 따로 관리 - val dueDate: LocalDate, // 납기일 + val dueDate: LocalDate?, // 납기일 val product: QuotationListItemProduct, ) { data class QuotationListItemCustomer( diff --git a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationStatusEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationStatusEnum.kt index 874d39e..c5da1e4 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationStatusEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/quotation/QuotationStatusEnum.kt @@ -6,7 +6,7 @@ enum class QuotationStatusEnum { UNKNOWN, // 알 수 없음, 기본값 PENDING, // 대기 REVIEW, // 검토 - APPROVED, // 승인 + APPROVAL, // 승인 REJECTED, // 반려 ; @@ -18,7 +18,7 @@ enum class QuotationStatusEnum { UNKNOWN -> "알 수 없음" PENDING -> "대기" REVIEW -> "검토" - APPROVED -> "승인" + APPROVAL -> "승인" REJECTED -> "반려" } @@ -40,7 +40,7 @@ enum class QuotationStatusEnum { UNKNOWN -> "알 수 없는 상태" PENDING -> "견적서 작성 완료, 검토 대기 중" REVIEW -> "견적서 검토 진행 중" - APPROVED -> "견적서가 승인되어 주문 전환 가능" + APPROVAL -> "견적서가 승인되어 주문 전환 가능" REJECTED -> "견적서가 반려됨" } @@ -53,7 +53,7 @@ enum class QuotationStatusEnum { UNKNOWN -> Color(0xFF9E9E9E) // Grey PENDING -> Color(0xFFFF9800) // Orange REVIEW -> Color(0xFF2196F3) // Blue - APPROVED -> Color(0xFF4CAF50) // Green + APPROVAL -> Color(0xFF4CAF50) // Green REJECTED -> Color(0xFFF44336) // Red } @@ -75,7 +75,7 @@ enum class QuotationStatusEnum { /** * 승인된 상태인지 확인 */ - fun isApproved(): Boolean = this == APPROVED + fun isApproved(): Boolean = this == APPROVAL /** * 반려된 상태인지 확인 @@ -100,7 +100,7 @@ enum class QuotationStatusEnum { /** * 알림이 필요한 상태인지 확인 */ - fun needsAlert(): Boolean = this == APPROVED || this == REJECTED + fun needsAlert(): Boolean = this == APPROVAL || this == REJECTED /** * 다음 가능한 상태 목록 반환 @@ -109,8 +109,8 @@ enum class QuotationStatusEnum { when (this) { UNKNOWN -> listOf(PENDING) PENDING -> listOf(REVIEW) - REVIEW -> listOf(APPROVED, REJECTED) - APPROVED -> emptyList() + REVIEW -> listOf(APPROVAL, REJECTED) + APPROVAL -> emptyList() REJECTED -> listOf(PENDING) } @@ -122,7 +122,7 @@ enum class QuotationStatusEnum { UNKNOWN -> 0 PENDING -> 25 REVIEW -> 50 - APPROVED -> 100 + APPROVAL -> 100 REJECTED -> 0 } @@ -187,6 +187,6 @@ enum class QuotationStatusEnum { * 완료 상태 목록 (승인, 반려) */ fun getCompletedStatuses(): List = - listOf(APPROVED, REJECTED) + listOf(APPROVAL, REJECTED) } } diff --git a/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrder.kt b/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrder.kt index 6c453d7..b3df16d 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrder.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrder.kt @@ -76,6 +76,7 @@ data class SalesOrderItem( val itemId: String, val itemName: String, val quantity: Int, + val uomName: String, // 단위명 val unitPrice: Long, val totalPrice: Long, ) { diff --git a/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrderStatusEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrderStatusEnum.kt index 2cac132..449313c 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrderStatusEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/sale/SalesOrderStatusEnum.kt @@ -4,6 +4,9 @@ import androidx.compose.ui.graphics.Color enum class SalesOrderStatusEnum { UNKNOWN, // 알 수 없음, 기본값 + PENDING, // 대기 + CONFIRMED, // 확정 + CANCELLED, // 취소 MATERIAL_PREPARATION, // 자재준비 IN_PRODUCTION, // 생산중 READY_FOR_SHIPMENT, // 출하준비 @@ -17,6 +20,9 @@ enum class SalesOrderStatusEnum { fun toKorean(): String = when (this) { UNKNOWN -> "알 수 없음" + PENDING -> "대기" + CONFIRMED -> "확정" + CANCELLED -> "취소" MATERIAL_PREPARATION -> "자재준비" IN_PRODUCTION -> "생산중" READY_FOR_SHIPMENT -> "출하준비" @@ -40,6 +46,9 @@ enum class SalesOrderStatusEnum { fun description(): String = when (this) { UNKNOWN -> "알 수 없는 상태" + PENDING -> "주문 접수 대기 중" + CONFIRMED -> "주문이 확정된 상태" + CANCELLED -> "주문이 취소된 상태" MATERIAL_PREPARATION -> "주문 자재를 준비하는 단계" IN_PRODUCTION -> "제품을 생산하는 단계" READY_FOR_SHIPMENT -> "출하를 준비하는 단계" @@ -54,6 +63,9 @@ enum class SalesOrderStatusEnum { fun toColor(): Color = when (this) { UNKNOWN -> Color(0xFF9E9E9E) // Grey + PENDING -> Color(0xFFFFC107) // Amber + CONFIRMED -> Color(0xFF8BC34A) // Light Green + CANCELLED -> Color(0xFFF44336) // Red MATERIAL_PREPARATION -> Color(0xFFFF9800) // Orange IN_PRODUCTION -> Color(0xFF2196F3) // Blue READY_FOR_SHIPMENT -> Color(0xFF00BCD4) // Cyan @@ -66,6 +78,21 @@ enum class SalesOrderStatusEnum { */ val code: String get() = this.name + /** + * 대기 상태인지 확인 + */ + fun isPending(): Boolean = this == PENDING + + /** + * 확정 상태인지 확인 + */ + fun isConfirmed(): Boolean = this == CONFIRMED + + /** + * 취소 상태인지 확인 + */ + fun isCancelled(): Boolean = this == CANCELLED + /** * 자재준비 상태인지 확인 */ @@ -103,29 +130,42 @@ enum class SalesOrderStatusEnum { this == READY_FOR_SHIPMENT || this == DELIVERING || this == DELIVERED /** - * 진행 중인 상태인지 확인 (완료 제외) + * 진행 중인 상태인지 확인 (완료, 취소 제외) */ - fun isInProgress(): Boolean = this != DELIVERED && this != UNKNOWN + fun isInProgress(): Boolean = this != DELIVERED && this != CANCELLED && this != UNKNOWN /** - * 취소 가능한 상태인지 확인 (배송 전) + * 취소 가능한 상태인지 확인 (출하준비 전) */ fun isCancellable(): Boolean = - this == MATERIAL_PREPARATION || this == IN_PRODUCTION + this == PENDING || this == CONFIRMED || this == MATERIAL_PREPARATION || this == IN_PRODUCTION + + /** + * 유효한 상태인지 확인 (UNKNOWN 제외) + */ + fun isValid(): Boolean = this != UNKNOWN + + /** + * 완료 상태인지 확인 (배송완료 또는 취소) + */ + fun isCompleted(): Boolean = this == DELIVERED || this == CANCELLED /** * 알림이 필요한 상태인지 확인 */ - fun needsAlert(): Boolean = this == READY_FOR_SHIPMENT || this == DELIVERED + fun needsAlert(): Boolean = this == CONFIRMED || this == READY_FOR_SHIPMENT || this == DELIVERED || this == CANCELLED /** * 다음 가능한 상태 목록 반환 */ fun getNextPossibleStatuses(): List = when (this) { - UNKNOWN -> listOf(MATERIAL_PREPARATION) - MATERIAL_PREPARATION -> listOf(IN_PRODUCTION) - IN_PRODUCTION -> listOf(READY_FOR_SHIPMENT) + UNKNOWN -> listOf(PENDING) + PENDING -> listOf(CONFIRMED, CANCELLED) + CONFIRMED -> listOf(MATERIAL_PREPARATION, CANCELLED) + CANCELLED -> emptyList() + MATERIAL_PREPARATION -> listOf(IN_PRODUCTION, CANCELLED) + IN_PRODUCTION -> listOf(READY_FOR_SHIPMENT, CANCELLED) READY_FOR_SHIPMENT -> listOf(DELIVERING) DELIVERING -> listOf(DELIVERED) DELIVERED -> emptyList() @@ -137,10 +177,13 @@ enum class SalesOrderStatusEnum { fun getProgress(): Int = when (this) { UNKNOWN -> 0 - MATERIAL_PREPARATION -> 20 - IN_PRODUCTION -> 40 - READY_FOR_SHIPMENT -> 60 - DELIVERING -> 80 + PENDING -> 10 + CONFIRMED -> 15 + CANCELLED -> 0 + MATERIAL_PREPARATION -> 30 + IN_PRODUCTION -> 50 + READY_FOR_SHIPMENT -> 70 + DELIVERING -> 85 DELIVERED -> 100 } @@ -211,5 +254,17 @@ enum class SalesOrderStatusEnum { */ fun getDeliveryStatuses(): List = listOf(READY_FOR_SHIPMENT, DELIVERING, DELIVERED) + + /** + * 완료 상태 목록 (승인, 반려) + */ + fun getCompletedStatuses(): List = + listOf(DELIVERED, CANCELLED) + + /** + * 대기/확정 상태 목록 + */ + fun getPendingStatuses(): List = + listOf(PENDING, CONFIRMED) } } diff --git a/app/src/main/java/com/autoever/everp/domain/repository/AlarmRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/AlarmRepository.kt index ecd56ed..061bc18 100644 --- a/app/src/main/java/com/autoever/everp/domain/repository/AlarmRepository.kt +++ b/app/src/main/java/com/autoever/everp/domain/repository/AlarmRepository.kt @@ -38,17 +38,15 @@ interface AlarmRepository { /** * 원격에서 개수 조회 후 로컬 갱신 + * 전체 개수와 읽지 않은 개수를 모두 조회하여 NotificationCount를 구성합니다. */ - suspend fun refreshNotificationCount( - status: NotificationStatusEnum = NotificationStatusEnum.UNKNOWN - ): Result + suspend fun refreshNotificationCount(): Result /** * 알림 개수 조회 + * 전체 개수와 읽지 않은 개수를 모두 조회하여 NotificationCount를 반환합니다. */ - suspend fun getNotificationCount( - status: NotificationStatusEnum = NotificationStatusEnum.UNKNOWN - ): Result + suspend fun getNotificationCount(): Result /** * 알림 목록 읽음 처리 diff --git a/app/src/main/java/com/autoever/everp/domain/repository/AuthRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..2a10e87 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/repository/AuthRepository.kt @@ -0,0 +1,23 @@ +package com.autoever.everp.domain.repository + +import com.autoever.everp.domain.model.auth.AccessToken +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + /** AccessToken 관찰 */ + val accessTokenFlow: Flow + + /** 인가 코드로 토큰 교환 후 로컬 저장 */ + suspend fun loginWithAuthorizationCode( + clientId: String, + redirectUri: String, + code: String, + codeVerifier: String, + ): Result + + /** 저장된 토큰 조회 (type 포함 문자열: e.g., "Bearer xxxxx") */ + suspend fun getAccessTokenWithType(): String? + + /** 로그아웃: 서버 로그아웃 + 로컬 삭제 */ + suspend fun logout(): Result +} diff --git a/app/src/main/java/com/autoever/everp/domain/repository/DashboardRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/DashboardRepository.kt new file mode 100644 index 0000000..25b7956 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/repository/DashboardRepository.kt @@ -0,0 +1,12 @@ +package com.autoever.everp.domain.repository + +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +import com.autoever.everp.domain.model.user.UserRoleEnum +import kotlinx.coroutines.flow.Flow + +interface DashboardRepository { + fun observeWorkflows(): Flow + suspend fun refreshWorkflows(role: UserRoleEnum): Result + suspend fun getWorkflows(role: UserRoleEnum): Result +} + diff --git a/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt index 390093b..9ddfc82 100644 --- a/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt +++ b/app/src/main/java/com/autoever/everp/domain/repository/FcmRepository.kt @@ -33,6 +33,6 @@ interface FcmRepository { suspend fun getArInvoiceDetail(invoiceId: String): Result suspend fun updateArInvoice(invoiceId: String, request: InvoiceUpdateRequestDto): Result -// suspend fun completeReceivable(invoiceId: String): Result + suspend fun completeReceivable(invoiceId: String): Result } diff --git a/app/src/main/java/com/autoever/everp/domain/repository/ImRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/ImRepository.kt new file mode 100644 index 0000000..30d4b8c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/repository/ImRepository.kt @@ -0,0 +1,10 @@ +package com.autoever.everp.domain.repository + +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import kotlinx.coroutines.flow.Flow + +interface ImRepository { + fun observeItemToggleList(): Flow> + suspend fun refreshItemToggleList(): Result + suspend fun getItemToggleList(): Result> +} diff --git a/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt new file mode 100644 index 0000000..aed86fe --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt @@ -0,0 +1,12 @@ +package com.autoever.everp.domain.repository + +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserTypeEnum +import kotlinx.coroutines.flow.Flow + +interface ProfileRepository { + fun observeProfile(): Flow + suspend fun refreshProfile(userType: UserTypeEnum): Result + suspend fun getProfile(userType: UserTypeEnum): Result +} + diff --git a/app/src/main/java/com/autoever/everp/domain/repository/SdRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/SdRepository.kt index ca5fbff..6687115 100644 --- a/app/src/main/java/com/autoever/everp/domain/repository/SdRepository.kt +++ b/app/src/main/java/com/autoever/everp/domain/repository/SdRepository.kt @@ -31,6 +31,11 @@ interface SdRepository { suspend fun refreshCustomerDetail(customerId: String): Result suspend fun getCustomerDetail(customerId: String): Result + suspend fun updateCustomer( + customerId: String, + request: com.autoever.everp.data.datasource.remote.http.service.CustomerUpdateRequestDto, + ): Result + // ========== 주문서 ========== fun observeSalesOrderList(): Flow> suspend fun refreshSalesOrderList(params: SalesOrderListParams): Result diff --git a/app/src/main/java/com/autoever/everp/service/fcm/MyFirebaseMessagingService.kt b/app/src/main/java/com/autoever/everp/service/fcm/MyFirebaseMessagingService.kt index 4342096..a0d3a95 100644 --- a/app/src/main/java/com/autoever/everp/service/fcm/MyFirebaseMessagingService.kt +++ b/app/src/main/java/com/autoever/everp/service/fcm/MyFirebaseMessagingService.kt @@ -9,22 +9,68 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import com.autoever.everp.R +import com.autoever.everp.domain.repository.AlarmRepository +import com.autoever.everp.domain.repository.DeviceInfoRepository +import com.autoever.everp.domain.repository.PushNotificationRepository import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject @AndroidEntryPoint class MyFirebaseMessagingService : FirebaseMessagingService() { + @Inject + lateinit var deviceInfoRepository: DeviceInfoRepository + + @Inject + lateinit var pushNotificationRepository: PushNotificationRepository + + @Inject + lateinit var alarmRepository: AlarmRepository + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** * FCM 토큰이 갱신될 때 호출됩니다. */ override fun onNewToken(token: String) { super.onNewToken(token) Timber.tag(TAG).i("FCM new token: $token") - // TODO: 서버로 토큰 업로드 API 호출 + + // 새 토큰을 서버에 등록 + registerFcmToken(token) + } + + /** + * FCM 토큰을 서버에 등록합니다. + */ + private fun registerFcmToken(token: String) { + serviceScope.launch { + try { + // Android ID 가져오기 + val androidId = deviceInfoRepository.getAndroidId() + Timber.tag(TAG).d("[INFO] Android ID 획득: $androidId") + + // 서버에 FCM 토큰 등록 + alarmRepository.registerFcmToken( + token = token, + deviceId = androidId, + deviceType = "ANDROID", + ) + Timber.tag(TAG).i("[INFO] FCM 토큰 서버 등록 완료") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "[ERROR] FCM 토큰 등록 실패: ${e.message}") + // FCM 토큰 등록 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + } + } } /** @@ -113,7 +159,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { val notificationId = NotificationIdProvider.next() try { val notificationManager = NotificationManagerCompat.from(this) - + // 알림 표시 가능 여부 확인 if (!notificationManager.areNotificationsEnabled()) { Timber.tag(TAG).w("시스템 설정에서 알림이 비활성화되어 있습니다.") @@ -121,7 +167,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } notificationManager.notify(notificationId, notification) - + Timber.tag(TAG).i("✅ 알림 표시 완료 (Notification ID: $notificationId)") Timber.tag(TAG).d("========================================") } catch (e: Exception) { @@ -167,6 +213,13 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { } } } + + override fun onDestroy() { + super.onDestroy() + // 2. 서비스가 종료될 때 스코프를 반드시 취소! -> 메모리 누수 방지 + serviceScope.cancel() + } + } private object NotificationIdProvider { diff --git a/app/src/main/java/com/autoever/everp/ui/MainScreen.kt b/app/src/main/java/com/autoever/everp/ui/MainScreen.kt index 4be6dfa..4ec56f7 100644 --- a/app/src/main/java/com/autoever/everp/ui/MainScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/MainScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.rememberNavController import com.autoever.everp.domain.model.user.UserTypeEnum import com.autoever.everp.ui.customer.CustomerApp import com.autoever.everp.ui.login.LoginScreen @@ -16,6 +17,8 @@ import timber.log.Timber @Composable fun MainScreen(viewModel: MainViewModel = hiltViewModel()) { + val navController = rememberNavController() + // ViewModel로부터 사용자 역할 상태를 관찰 val userRole by viewModel.userRole.collectAsState() @@ -38,8 +41,8 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel()) { // 역할 상태에 따라 적절한 UI를 렌더링 when (userRole) { - UserTypeEnum.CUSTOMER -> CustomerApp() - UserTypeEnum.SUPPLIER -> SupplierApp() + UserTypeEnum.CUSTOMER -> CustomerApp(navController) + UserTypeEnum.SUPPLIER -> SupplierApp(navController) else -> LoginScreen { // onLoginSuccess = { role -> // viewModel.updateUserRole(role) diff --git a/app/src/main/java/com/autoever/everp/ui/auth/AuthViewModel.kt b/app/src/main/java/com/autoever/everp/ui/auth/AuthViewModel.kt index eaeaf18..62faaef 100644 --- a/app/src/main/java/com/autoever/everp/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/auth/AuthViewModel.kt @@ -7,6 +7,7 @@ import com.autoever.everp.auth.config.AuthConfig import com.autoever.everp.auth.pkce.PKCEGenerator import com.autoever.everp.auth.pkce.PKCEPair import com.autoever.everp.auth.pkce.StateGenerator +import timber.log.Timber class AuthViewModel : ViewModel() { var requestUri: Uri? = null @@ -27,7 +28,7 @@ class AuthViewModel : ViewModel() { isLoading = true errorMessage = null - Log.i(TAG, "[INFO] 인가(Authorization) 플로우 시작") + Timber.tag(TAG).i("[INFO] 인가(Authorization) 플로우 시작") try { val pair = PKCEGenerator.generatePair() val st = StateGenerator.makeState() @@ -40,18 +41,18 @@ class AuthViewModel : ViewModel() { ) requestUri = uri isLoading = false - Log.i(TAG, "[INFO] Authorization URL 생성 완료: ${uri}") + Timber.tag(TAG).i("[INFO] Authorization URL 생성 완료: ${uri}") } catch (e: Exception) { errorMessage = e.message isLoading = false - Log.e(TAG, "[ERROR] 인가 URL 생성 중 오류: ${e.message}") + Timber.tag(TAG).e("[ERROR] 인가 URL 생성 중 오류: ${e.message}") } } fun handleRedirect(url: Uri, onCode: (code: String, codeVerifier: String) -> Unit) { val cfg = config ?: run { errorMessage = "인가 설정 정보 없음" - Log.e(TAG, "[ERROR] 인가 설정 정보가 없습니다.") + Timber.tag(TAG).e("[ERROR] 인가 설정 정보가 없습니다.") return } @@ -66,18 +67,18 @@ class AuthViewModel : ViewModel() { val receivedState = url.getQueryParameter("state") if (code.isNullOrEmpty() || receivedState.isNullOrEmpty() || receivedState != state) { errorMessage = "state 또는 code 검증 실패" - Log.e(TAG, "[ERROR] state 또는 code 검증 실패 (state 불일치 혹은 누락)") + Timber.tag(TAG).e("[ERROR] state 또는 code 검증 실패 (state 불일치 혹은 누락)") return } val verifier = pkce?.codeVerifier if (verifier.isNullOrEmpty()) { errorMessage = "code_verifier 추출 실패" - Log.e(TAG, "[ERROR] code_verifier 추출 실패 (PKCE 초기화 누락 가능성)") + Timber.tag(TAG).e("[ERROR] code_verifier 추출 실패 (PKCE 초기화 누락 가능성)") return } - Log.i(TAG, "[INFO] 인가 코드 수신 완료: 토큰 교환 진행 가능") + Timber.tag(TAG).i("[INFO] 인가 코드 수신 완료: 토큰 교환 진행 가능") onCode(code, verifier) } @@ -88,7 +89,7 @@ class AuthViewModel : ViewModel() { pkce = null state = "" config = null - Log.i(TAG, "[INFO] AuthViewModel 상태 초기화 완료") + Timber.tag(TAG).i("[INFO] AuthViewModel 상태 초기화 완료") } private companion object { diff --git a/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt b/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt new file mode 100644 index 0000000..4b92478 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt @@ -0,0 +1,232 @@ +package com.autoever.everp.ui.common + +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.ui.customer.CustomerSubNavigationItem +import com.autoever.everp.ui.supplier.SupplierSubNavigationItem + +/** + * 최근 활동 카드 컴포저블 + * + * TODO CUSTOMER, SUPPLIER 공통으로 이용하니 그에 따른 수정 필요 + */ + +@Composable +fun RecentActivityCard( + category: String, + status: String, + title: String, + date: String, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + // Category badge (파란색) + StatusBadge( + text = category, + color = androidx.compose.ui.graphics.Color(0xFF2196F3), + ) + // Status badge (회색) + StatusBadge( + text = status, + color = androidx.compose.ui.graphics.Color(0xFF9E9E9E), + ) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Icon( + imageVector = Icons.Default.ArrowForward, + contentDescription = "상세보기", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +fun navigateToWorkflowDetail( + navController: NavController, + category: DashboardTapEnum, + workflowId: String, +) { + when (category) { + DashboardTapEnum.QT -> { // 견적 + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = workflowId) + ) + } + + DashboardTapEnum.SO -> { // 주문 + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(workflowId) + ) + } + + DashboardTapEnum.AP -> { // 매입 + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = workflowId, + isAp = true, + ), + ) + } + + DashboardTapEnum.AR -> { // 매출 + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = workflowId, + isAp = false, + ) + ) + } + + DashboardTapEnum.PO -> { // 발주 + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute( + workflowId + ) + ) + } + // Customer 화면에서는 발주, 구매 등 상세로 이동하지 않음 + else -> { + // 알 수 없는 카테고리 또는 이동 불가능한 카테고리 + } + } +} + +/* +@Composable +private fun RecentActivityCard( + category: String, + status: String, + title: String, + date: String, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + // Category badge (파란색) + StatusBadge( + text = category, + color = androidx.compose.ui.graphics.Color(0xFF2196F3), + ) + // Status badge (회색) + StatusBadge( + text = status, + color = androidx.compose.ui.graphics.Color(0xFF9E9E9E), + ) + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Icon( + imageVector = Icons.Default.ArrowForward, + contentDescription = "상세보기", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun getCategoryDisplayName(tabCode: String): String { + return when (tabCode.uppercase()) { + "QUOTATION", "견적" -> "견적" + "ORDER", "주문", "SALES_ORDER" -> "주문" + "INVOICE", "전표", "AP_INVOICE", "AR_INVOICE" -> "전표" + "PURCHASE_ORDER", "발주" -> "발주" + else -> tabCode + } +} + +private fun navigateToDetail( + navController: NavController, + category: String, + workflowId: String, +) { + when (category.uppercase()) { + "PURCHASE_ORDER", "발주" -> { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(workflowId) + ) + } + "INVOICE", "전표", "AP_INVOICE", "AR_INVOICE" -> { + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = workflowId, + isAp = category.contains("AP", ignoreCase = true), + ), + ) + } + } +} + + */ diff --git a/app/src/main/java/com/autoever/everp/ui/common/components/ListCard.kt b/app/src/main/java/com/autoever/everp/ui/common/components/ListCard.kt new file mode 100644 index 0000000..d928b7e --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/components/ListCard.kt @@ -0,0 +1,77 @@ +package com.autoever.everp.ui.common.components + +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.padding +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 + +/** + * 리스트 카드 컴포넌트 + * 견적, 주문, 전표 등 다양한 화면에서 재사용 가능 + */ +@Composable +fun ListCard( + id: String, + title: String, + statusBadge: @Composable () -> Unit, + details: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + trailingContent: (@Composable () -> Unit)? = null, +) { + Card( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = id, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + statusBadge() + } + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp), + ) + + details() + + trailingContent?.invoke() + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/common/components/QuickActionCard.kt b/app/src/main/java/com/autoever/everp/ui/common/components/QuickActionCard.kt new file mode 100644 index 0000000..07152ab --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/components/QuickActionCard.kt @@ -0,0 +1,80 @@ +package com.autoever.everp.ui.common.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.Add +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Receipt +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +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.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * 빠른 작업 카드 컴포넌트 + * 홈 화면에서 사용 + */ +@Composable +fun QuickActionCard( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +/** + * 빠른 작업 아이콘 상수 + */ +object QuickActionIcons { + val QuotationRequest = Icons.Default.Add // 견적 요청 아이콘 + val QuotationList = Icons.Default.Description // 견적 목록 아이콘 + val PurchaseOrderList = Icons.Default.ShoppingCart // 주문 목록 아이콘 + val InvoiceList = Icons.Default.Receipt // 매입 & 매출 전표 목록 아이콘 + val SalesOrderList = Icons.Default.Description // 발주 목록 아이콘 + +} + diff --git a/app/src/main/java/com/autoever/everp/ui/common/components/SearchBar.kt b/app/src/main/java/com/autoever/everp/ui/common/components/SearchBar.kt new file mode 100644 index 0000000..3cf7378 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/components/SearchBar.kt @@ -0,0 +1,61 @@ +package com.autoever.everp.ui.common.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp + +/** + * 검색 바 컴포넌트 + * 다양한 화면에서 재사용 가능 + */ +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + onSearch: (() -> Unit)? = null, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { + Text(text = placeholder) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "검색", + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch?.invoke() + keyboardController?.hide() + }, + ), + colors = OutlinedTextFieldDefaults.colors(), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + diff --git a/app/src/main/java/com/autoever/everp/ui/common/components/StatusBadge.kt b/app/src/main/java/com/autoever/everp/ui/common/components/StatusBadge.kt new file mode 100644 index 0000000..5bb365b --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/components/StatusBadge.kt @@ -0,0 +1,37 @@ +package com.autoever.everp.ui.common.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * 상태 배지 컴포넌트 + * 견적, 주문 등 다양한 화면에서 재사용 가능 + */ +@Composable +fun StatusBadge( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Text( + text = text, + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = modifier + .background( + color = color, + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerApp.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerApp.kt index cf4dba0..1a3d855 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerApp.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerApp.kt @@ -1,18 +1,25 @@ package com.autoever.everp.ui.customer +import android.R.attr.defaultValue +import android.R.attr.type import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavArgument +import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.autoever.everp.ui.navigation.CustomNavigationBar @Composable -fun CustomerApp() { +fun CustomerApp( + loginNavController: NavHostController +) { val navController = rememberNavController() Scaffold( @@ -24,21 +31,86 @@ fun CustomerApp() { startDestination = CustomerNavigationItem.Home.route, modifier = Modifier.padding(innerPadding), ) { + // 고객사 메인 네비게이션 아이템들 composable(CustomerNavigationItem.Home.route) { - CustomerHomeScreen() // 고객사 홈 화면 + CustomerHomeScreen(navController = navController) // 고객사 홈 화면 } composable(CustomerNavigationItem.Quotation.route) { - CustomerQuotationScreen() // 견적 화면 + CustomerQuotationScreen(navController = navController) // 견적 화면 } - composable(CustomerNavigationItem.Order.route) { - CustomerOrderScreen() // 주문 화면 + composable(CustomerNavigationItem.SalesOrder.route) { + CustomerOrderScreen(navController = navController) // 주문 화면 } - composable(CustomerNavigationItem.Voucher.route) { - CustomerVoucherScreen() // 전표 화면 + composable(CustomerNavigationItem.Invoice.route) { + CustomerVoucherScreen(navController = navController) // 전표 화면 } composable(CustomerNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - CustomerProfileScreen() // 고객사 프로필 화면 + CustomerProfileScreen( + loginNavController = loginNavController, + navController = navController + ) // 고객사 프로필 화면 + } + // 고객사 서브 네비게이션 아이템들 + composable(CustomerSubNavigationItem.QuotationCreateItem.route) { + QuotationCreateScreen(navController = navController) + } + composable( + route = CustomerSubNavigationItem.QuotationDetailItem.route, + ) { backStackEntry -> + val quotationId = backStackEntry.arguments + ?.getString(CustomerSubNavigationItem.QuotationDetailItem.ARG_ID) + ?: return@composable + QuotationDetailScreen( + navController = navController, + quotationId = quotationId, + ) + } + composable( + route = CustomerSubNavigationItem.SalesOrderDetailItem.route, + ) { backStackEntry -> + val salesOrderId = backStackEntry.arguments + ?.getString(CustomerSubNavigationItem.SalesOrderDetailItem.ARG_ID) + ?: return@composable + SalesOrderDetailScreen( + navController = navController, + salesOrderId = salesOrderId, + ) + } + composable( + route = CustomerSubNavigationItem.InvoiceDetailItem.route, + arguments = listOf( + navArgument(CustomerSubNavigationItem.InvoiceDetailItem.ARG_ID) { + type = NavType.StringType + }, + navArgument(CustomerSubNavigationItem.InvoiceDetailItem.ARG_IS_AP) { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { backStackEntry -> + val invoiceId = backStackEntry.arguments + ?.getString(CustomerSubNavigationItem.InvoiceDetailItem.ARG_ID) + ?: return@composable + val isAp = backStackEntry.arguments + ?.getBoolean(CustomerSubNavigationItem.InvoiceDetailItem.ARG_IS_AP) + ?: false + InvoiceDetailScreen( + navController = navController, + invoiceId = invoiceId, + isAp = isAp, + ) + } + composable( + route = CustomerSubNavigationItem.ProfileEditItem.route, + ) { + CustomerProfileEditScreen(navController = navController) + } + + composable( + route = CustomerSubNavigationItem.NotificationItem.route, + ) { + NotificationScreen(navController = navController) } } } @@ -50,7 +122,7 @@ fun CustomerApp() { ) @Composable fun CustomerAppPreview() { - CustomerApp() + CustomerApp(rememberNavController()) } @Preview(showBackground = true) diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeScreen.kt index 4443949..bf50755 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeScreen.kt @@ -1,10 +1,189 @@ package com.autoever.everp.ui.customer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +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.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.ui.common.RecentActivityCard +import com.autoever.everp.ui.common.components.QuickActionCard +import com.autoever.everp.ui.common.components.QuickActionIcons +import com.autoever.everp.ui.common.navigateToWorkflowDetail +import timber.log.Timber +import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun CustomerHomeScreen() { - // 고객용 홈 화면 UI 구현 - Text(text = "Customer Home Screen") +fun CustomerHomeScreen( + navController: NavController, + viewModel: CustomerHomeViewModel = hiltViewModel(), +) { + val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() + val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val hasUnreadNotifications by viewModel.hasUnreadNotifications.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("차량 외장재 관리") }, + actions = { + Box( + modifier = Modifier + .padding(end = 8.dp) + .size(48.dp) + .padding(top = 16.dp, end = 16.dp), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = { + navController.navigate(CustomerSubNavigationItem.NotificationItem.route) + }, + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = "알림", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + // 읽지 않은 알림이 있으면 빨간색 점 표시 + if (hasUnreadNotifications) { + Surface( + modifier = Modifier + .size(8.dp) + .align(Alignment.TopEnd), + shape = CircleShape, + color = Color.Red, + ) { + // 빨간색 점 + } + } + } + }, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + Text( + text = "안녕하세요!", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "오늘도 효율적인 업무 관리를 시작해보세요.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { + Text( + text = "빠른 작업", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + item { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.height(200.dp), + ) { + item { + QuickActionCard( + icon = QuickActionIcons.QuotationRequest, + label = "견적 요청", + onClick = { navController.navigate(CustomerSubNavigationItem.QuotationCreateItem.route) }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.QuotationList, + label = "견적 목록", + onClick = { navController.navigate(CustomerNavigationItem.Quotation.route) }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.PurchaseOrderList, + label = "주문 관리", + onClick = { navController.navigate(CustomerNavigationItem.SalesOrder.route) }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.InvoiceList, + label = "매입전표", + onClick = { navController.navigate(CustomerNavigationItem.Invoice.route) }, + ) + } + } + } + + item { + Text( + text = "최근 활동", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (isLoading) { + item { + Text(text = "로딩 중...") + } + } else { + recentActivities.forEach { activity -> + item { + val category = categoryMap[activity.id] ?: DashboardTapEnum.UNKNOWN + RecentActivityCard( + category = category.toKorean(), + status = activity.status, + title = activity.description, + date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { + if (activity.tabCode.isCustomerRelated()) { + navigateToWorkflowDetail(navController, category, activity.id) + } + }, + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt new file mode 100644 index 0000000..ffa803c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -0,0 +1,100 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +import com.autoever.everp.domain.model.notification.NotificationStatusEnum +import com.autoever.everp.domain.repository.AlarmRepository +import com.autoever.everp.domain.repository.DashboardRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerHomeViewModel @Inject constructor( + private val dashboardRepository: DashboardRepository, + private val userRepository: UserRepository, + private val alarmRepository: AlarmRepository, +) : ViewModel() { + + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> + get() = _recentActivities.asStateFlow() + + private val _categoryMap = MutableStateFlow>(emptyMap()) + val categoryMap: StateFlow> + get() = _categoryMap.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _hasUnreadNotifications = MutableStateFlow(false) + val hasUnreadNotifications: StateFlow = _hasUnreadNotifications.asStateFlow() + + init { + loadRecentActivities() + observeNotificationCount() + refreshNotificationCount() + } + + fun loadRecentActivities() { + viewModelScope.launch { + _isLoading.value = true + try { + userRepository.getUserInfo().onSuccess { userInfo -> + val role = userInfo.userRole + dashboardRepository.refreshWorkflows(role).onSuccess { + dashboardRepository.getWorkflows(role).onSuccess { workflows -> + // tabs를 날짜순으로 정렬 + val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } + .take(5) // 최근 5개만 + _recentActivities.value = sortedTabs + _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } + }.onFailure { e -> + Timber.e(e, "워크플로우 조회 실패") + } + }.onFailure { e -> + Timber.e(e, "워크플로우 갱신 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 조회 실패") + } + } catch (e: Exception) { + Timber.e(e, "최근 활동 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadRecentActivities() + refreshNotificationCount() + } + + private fun observeNotificationCount() { + alarmRepository.observeNotificationCount() + .onEach { count -> + _hasUnreadNotifications.value = count.unreadCount >= 1 + } + .launchIn(viewModelScope) + } + + private fun refreshNotificationCount() { + viewModelScope.launch { + alarmRepository.refreshNotificationCount() + .onFailure { e -> + Timber.e(e, "알림 개수 갱신 실패") + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerNavigationItem.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerNavigationItem.kt index 33c6123..2773c58 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerNavigationItem.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerNavigationItem.kt @@ -25,13 +25,56 @@ sealed class CustomerNavigationItem( object Quotation : CustomerNavigationItem("customer_quotation", "견적", Icons.Outlined.RequestPage, Icons.Filled.RequestPage) - object Order : CustomerNavigationItem("customer_order", "주문", Icons.Outlined.ShoppingBag, Icons.Filled.ShoppingBag) + object SalesOrder : + CustomerNavigationItem("customer_sales_order", "주문", Icons.Outlined.ShoppingBag, Icons.Filled.ShoppingBag) - object Voucher : CustomerNavigationItem("customer_voucher", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) + object Invoice : CustomerNavigationItem("customer_invoice", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) object Profile : CustomerNavigationItem("customer_profile", "프로필", Icons.Outlined.Person, Icons.Filled.Person) companion object { - val allDestinations = listOf(Home, Quotation, Order, Voucher, Profile) + val allDestinations = listOf(Home, Quotation, SalesOrder, Invoice, Profile) } } + +sealed class CustomerSubNavigationItem( + val route: String, + val label: String, +) { + object QuotationCreateItem : CustomerSubNavigationItem("customer_quotation_create", "견적서 생성") + + object QuotationDetailItem : + CustomerSubNavigationItem("customer_quotation_detail/{quotationId}", "견적서 상세") { + + const val ARG_ID = "quotationId" + + fun createRoute(quotationId: String): String { + return "customer_quotation_detail/$quotationId" + } + } + + object SalesOrderDetailItem : + CustomerSubNavigationItem("customer_order_detail/{salesOrderId}", "주문서 상세") { + + const val ARG_ID = "salesOrderId" + + fun createRoute(salesOrderId: String): String { + return "customer_order_detail/$salesOrderId" + } + } + + object InvoiceDetailItem : + CustomerSubNavigationItem("customer_invoice_detail/{invoiceId}?isAp={isAp}", "전표 상세") { + + const val ARG_ID = "invoiceId" + const val ARG_IS_AP = "isAp" + + fun createRoute(invoiceId: String, isAp: Boolean = false): String { + return "customer_invoice_detail/$invoiceId?isAp=$isAp" + } + } + + object ProfileEditItem : CustomerSubNavigationItem("customer_profile_edit", "프로필 수정") + + object NotificationItem : CustomerSubNavigationItem("customer_notification", "알림 목록") +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderScreen.kt index 48c512e..57ee587 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderScreen.kt @@ -1,10 +1,135 @@ package com.autoever.everp.ui.customer +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.domain.model.sale.SalesOrderSearchTypeEnum +import com.autoever.everp.ui.common.components.ListCard +import com.autoever.everp.ui.common.components.SearchBar +import com.autoever.everp.ui.common.components.StatusBadge +import java.text.NumberFormat +import java.util.Locale @Composable -fun CustomerOrderScreen() { - // 고객용 주문 화면 UI 구현 - Text("Customer Order Screen") +fun CustomerOrderScreen( + navController: NavController, + viewModel: CustomerOrderViewModel = hiltViewModel(), +) { + val orderList by viewModel.orderList.collectAsState() + val searchParams by viewModel.searchParams.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 헤더 + Text( + text = "주문 관리", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + // 검색 바 + SearchBar( + query = searchParams.searchKeyword, + onQueryChange = { viewModel.updateSearchQuery(it, SalesOrderSearchTypeEnum.SALES_ORDER_NUMBER) }, + placeholder = "주문번호로 검색", + onSearch = { viewModel.search() }, + ) + + // 리스트 + when (uiState) { + is com.autoever.everp.utils.state.UiResult.Loading -> { + Text( + text = "로딩 중...", + modifier = Modifier.padding(16.dp), + ) + } + + is com.autoever.everp.utils.state.UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as com.autoever.everp.utils.state.UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.retry() }) { + Text("다시 시도") + } + } + } + + is com.autoever.everp.utils.state.UiResult.Success -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(orderList) { order -> + ListCard( + id = order.salesOrderNumber, + title = "${order.customerName} - ${order.managerName}", + statusBadge = { + StatusBadge( + text = order.statusCode.displayName(), + color = order.statusCode.toColor(), + ) + }, + details = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "납기일: ${order.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "주문금액: ${formatCurrency(order.totalAmount)}원", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + } + }, + trailingContent = { + Button( + onClick = { + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute( + order.salesOrderId + ) + ) + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("상세보기") + } + }, + onClick = { + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute( + order.salesOrderId + ) + ) + }, + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt new file mode 100644 index 0000000..afb24eb --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt @@ -0,0 +1,125 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.sale.SalesOrderListItem +import com.autoever.everp.domain.model.sale.SalesOrderListParams +import com.autoever.everp.domain.model.sale.SalesOrderSearchTypeEnum +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerOrderViewModel @Inject constructor( + private val sdRepository: SdRepository, +) : ViewModel() { + + // 로딩/에러 상태만 관리 + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> + get() = _uiState.asStateFlow() + + // 실제 리스트는 별도로 누적 관리 + private val _orderList = MutableStateFlow>(emptyList()) + val orderList: StateFlow> + get() = _orderList.asStateFlow() + + private val _totalPages = MutableStateFlow(0) + val totalPages: StateFlow + get() = _totalPages.asStateFlow() + + private val _hasMore = MutableStateFlow(true) + val hasMore: StateFlow + get() = _hasMore.asStateFlow() + + private val _searchParams = MutableStateFlow( + SalesOrderListParams( + startDate = null, + endDate = null, + searchKeyword = "", + searchType = SalesOrderSearchTypeEnum.UNKNOWN, + statusFilter = com.autoever.everp.domain.model.sale.SalesOrderStatusEnum.UNKNOWN, + page = 0, + size = 20, + ), + ) + val searchParams: StateFlow + get() = _searchParams.asStateFlow() + + init { + loadOrders() + } + + fun loadOrders(append: Boolean = false) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + + sdRepository.refreshSalesOrderList(searchParams.value) + .onSuccess { + // refresh 후 observe를 통해 최신 데이터 가져오기 + sdRepository.getSalesOrderList(searchParams.value) + .onSuccess { pageResponse -> + if (append) { + // 페이지네이션: 기존 리스트에 추가 + _orderList.value = _orderList.value + pageResponse.content + } else { + // 새로운 검색: 리스트 교체 + _orderList.value = pageResponse.content + } + _totalPages.value = pageResponse.page.totalPages + _hasMore.value = !pageResponse.page.hasNext + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "주문 목록 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "주문 목록 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun loadNextPage() { + if (_uiState.value is UiResult.Loading || !_hasMore.value) return + + _searchParams.value = _searchParams.value.copy( + page = _searchParams.value.page + 1, + ) + loadOrders(append = true) + } + + fun updateSearchQuery( + query: String, + queryType: SalesOrderSearchTypeEnum = SalesOrderSearchTypeEnum.UNKNOWN, + ) { + _searchParams.value = _searchParams.value.copy( + searchKeyword = query, + searchType = queryType, + page = 0, // 검색 시 페이지 초기화 + ) + } + + fun search() { + loadOrders(append = false) // 새로운 검색 + } + + fun retry() { + loadOrders(append = false) + } + + fun refresh() { + _searchParams.value = _searchParams.value.copy(page = 0) + _orderList.value = emptyList() + loadOrders(append = false) + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt new file mode 100644 index 0000000..9cda8a7 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt @@ -0,0 +1,220 @@ +package com.autoever.everp.ui.customer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomerProfileEditScreen( + navController: NavController, + viewModel: CustomerProfileEditViewModel = hiltViewModel(), +) { + val profile by viewModel.profile.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() + val isSaving by viewModel.isSaving.collectAsState() + + var companyName by remember { mutableStateOf("") } + var businessNumber by remember { mutableStateOf("") } + var baseAddress by remember { mutableStateOf("") } + var detailAddress by remember { mutableStateOf("") } + var officePhone by remember { mutableStateOf("") } + var userPhoneNumber by remember { mutableStateOf("") } + + LaunchedEffect(profile) { + profile?.let { p -> + companyName = p.companyName + businessNumber = p.businessNumber + baseAddress = p.baseAddress + detailAddress = p.detailAddress + officePhone = p.officePhone + userPhoneNumber = p.userPhoneNumber + } + } + + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text("프로필 편집") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 고객사 정보 섹션 + Text( + text = "고객사 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + OutlinedTextField( + value = companyName, + onValueChange = { companyName = it }, + label = { Text("회사명 *") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = businessNumber, + onValueChange = { businessNumber = it }, + label = { Text("사업자등록번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = baseAddress, + onValueChange = { baseAddress = it }, + label = { Text("기본 주소") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = detailAddress, + onValueChange = { detailAddress = it }, + label = { Text("상세 주소") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = officePhone, + onValueChange = { officePhone = it }, + label = { Text("회사 전화번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 개인 정보 섹션 + Text( + text = "개인 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + OutlinedTextField( + value = profile?.userName ?: userInfo?.userName ?: "", + onValueChange = { /* 이름은 수정 불가 */ }, + label = { Text("이름 *") }, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = profile?.userEmail ?: userInfo?.email ?: "", + onValueChange = { /* 이메일은 수정 불가 */ }, + label = { Text("이메일 *") }, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = userPhoneNumber, + onValueChange = { userPhoneNumber = it }, + label = { Text("휴대폰 번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 저장 버튼 + Button( + onClick = { + viewModel.saveProfile( + companyName = companyName, + businessNumber = businessNumber, + baseAddress = baseAddress, + detailAddress = detailAddress, + officePhone = officePhone, + userPhoneNumber = userPhoneNumber, + onSuccess = { + navController.popBackStack() + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isSaving, + ) { + if (isSaving) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + ) + } + Text(if (isSaving) "저장 중..." else "저장") + } + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditViewModel.kt new file mode 100644 index 0000000..50e2bdf --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditViewModel.kt @@ -0,0 +1,85 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.ProfileRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerProfileEditViewModel @Inject constructor( + private val userRepository: UserRepository, + private val profileRepository: ProfileRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _profile = MutableStateFlow(null) + val profile: StateFlow = _profile.asStateFlow() + + private val _isSaving = MutableStateFlow(false) + val isSaving: StateFlow = _isSaving.asStateFlow() + + init { + // Flow에서 profile 업데이트 구독 + profileRepository.observeProfile() + .onEach { profile -> + _profile.value = profile + } + .launchIn(viewModelScope) + + loadData() + } + + private fun loadData() { + viewModelScope.launch { + userRepository.getUserInfo().onSuccess { user -> + _userInfo.value = user + // 프로필 정보 로드 + profileRepository.refreshProfile(user.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") + } + } + } + + fun saveProfile( + companyName: String, + businessNumber: String, + baseAddress: String, + detailAddress: String, + officePhone: String, + userPhoneNumber: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + _isSaving.value = true + try { + // TODO: ProfileRepository에 updateProfile 메서드가 추가되면 구현 + // 현재는 Profile 정보만 표시하고, 업데이트 기능은 나중에 추가 예정 + Timber.w("프로필 업데이트 기능은 아직 구현되지 않았습니다.") + // 임시로 성공 처리 + onSuccess() + } catch (e: Exception) { + Timber.e(e, "프로필 저장 실패") + } finally { + _isSaving.value = false + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt index 6fb58aa..a247269 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileScreen.kt @@ -1,10 +1,201 @@ package com.autoever.everp.ui.customer +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.size +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.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController @Composable -fun CustomerProfileScreen() { - // 고객용 홈 화면 UI 구현 - Text("Customer Profile Screen") +fun CustomerProfileScreen( + loginNavController: NavController, + navController: NavController, + viewModel: CustomerProfileViewModel = hiltViewModel(), +) { + val userInfo by viewModel.userInfo.collectAsState() + val profile by viewModel.profile.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 헤더 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "프로필", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + androidx.compose.material3.TextButton( + onClick = { + navController.navigate(CustomerSubNavigationItem.ProfileEditItem.route) + }, + ) { + Text("편집") + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 사용자 프로필 아이콘 + Icon( + imageVector = Icons.Default.Person, + contentDescription = "프로필", + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .align(Alignment.CenterHorizontally), + tint = MaterialTheme.colorScheme.primary, + ) + + // 사용자 이름과 직책 + Text( + text = userInfo?.userName ?: "로딩 중...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp), + ) + + Text( + text = "${userInfo?.userType?.name ?: ""}·${userInfo?.userRole?.name ?: ""}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // 고객사 정보 섹션 + Text( + text = "고객사 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + ProfileField( + label = "회사명 *", + value = profile?.companyName ?: "", + ) + ProfileField( + label = "사업자등록번호", + value = profile?.businessNumber ?: "", + ) + ProfileField( + label = "회사 주소", + value = profile?.fullAddress ?: "", + ) + ProfileField( + label = "회사 전화번호", + value = profile?.officePhone ?: "", + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 개인 정보 섹션 + Text( + text = "개인 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + ProfileField( + label = "이름 *", + value = profile?.userName ?: userInfo?.userName ?: "", + ) + ProfileField( + label = "이메일 *", + value = profile?.userEmail ?: userInfo?.email ?: "", + ) + ProfileField( + label = "휴대폰 번호", + value = profile?.userPhoneNumber ?: "", + ) + } + } + + Button( + onClick = { + viewModel.logout { + loginNavController.navigate("login") { + popUpTo(0) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + ) { } + } +} + +@Composable +private fun ProfileField( + label: String, + value: String, +) { + OutlinedTextField( + value = value, + onValueChange = { /* 편집 모드에서만 */ }, + label = { Text(label) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt new file mode 100644 index 0000000..5983e6d --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt @@ -0,0 +1,87 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.auth.session.SessionManager +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.ProfileRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerProfileViewModel @Inject constructor( + private val sessionManager: SessionManager, + private val userRepository: UserRepository, + private val profileRepository: ProfileRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _profile = MutableStateFlow(null) + val profile: StateFlow = _profile.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + // Flow에서 profile 업데이트 구독 + profileRepository.observeProfile() + .onEach { profile -> + _profile.value = profile + } + .launchIn(viewModelScope) + + loadUserInfo() + } + + fun loadUserInfo() { + viewModelScope.launch { + _isLoading.value = true + try { + // 사용자 정보 로드 + userRepository.getUserInfo().onSuccess { userInfo -> + _userInfo.value = userInfo + // 프로필 정보 로드 + profileRepository.refreshProfile(userInfo.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") + } + } catch (e: Exception) { + Timber.e(e, "정보 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadUserInfo() + } + + fun logout(onSuccess: () -> Unit) { + viewModelScope.launch { + sessionManager.signOut() + try { + userRepository.logout() + onSuccess() + Timber.i("로그아웃 성공") + } catch (e: Exception) { + Timber.e(e, "로그아웃 실패") + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationScreen.kt index ef93b04..c250de1 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationScreen.kt @@ -1,10 +1,195 @@ package com.autoever.everp.ui.customer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.ListCard +import com.autoever.everp.ui.common.components.SearchBar +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.time.format.DateTimeFormatter @Composable -fun CustomerQuotationScreen() { - // 고객용 견적 화면 UI 구현 - Text("Customer Quotation Screen") +fun CustomerQuotationScreen( + navController: NavController, + viewModel: CustomerQuotationViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val quotationList by viewModel.quotationList.collectAsStateWithLifecycle() + val totalPage by viewModel.totalPages.collectAsStateWithLifecycle() + val hasMore by viewModel.hasMore.collectAsStateWithLifecycle() + val searchParams by viewModel.searchParams.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + + // 무한 스크롤 + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + if (lastVisibleIndex == totalPage - 1 && hasMore) { + viewModel.loadNextPage() + } + } + } + + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 헤더 + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "견적 관리", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Button( + onClick = { navController.navigate(CustomerSubNavigationItem.QuotationCreateItem.route) }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("견적 요청") + } + } + + // 검색 바 + SearchBar( + query = searchParams.search, + onQueryChange = { viewModel.updateSearchQuery(it) }, + placeholder = "견적번호, 고객명, 담당자로 검색", + onSearch = { viewModel.search() }, + ) + + // 리스트 + Box(modifier = Modifier.fillMaxSize()) { + when { + // 초기 로딩 + uiState is UiResult.Loading && quotationList.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // 에러 (리스트가 비어있을 때만) + uiState is UiResult.Error && quotationList.isEmpty() -> { + val error = (uiState as UiResult.Error).exception + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("오류: ${error.message}") + Button(onClick = { viewModel.retry() }) { + Text("재시도") + } + } + } + } + + // 리스트 표시 + else -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items(quotationList, key = { it.number }) { quotation -> + ListCard( + id = quotation.number, + title = "${quotation.customer.name} - ${quotation.product.productId}", + statusBadge = { + StatusBadge( + text = quotation.status.displayName(), + color = quotation.status.toColor(), + ) + }, + details = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "고객명: ${quotation.customer.name}", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "납기일: ${ + quotation.dueDate?.format( + DateTimeFormatter.ofPattern("yyyy-MM-dd") + ) ?: "미정" + }", + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + onClick = { + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = quotation.id) + ) + }, + ) + } + + // 페이지네이션 로딩 + if (uiState is UiResult.Loading && quotationList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + + // 마지막 페이지 표시 + if (!hasMore && quotationList.isNotEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "마지막 페이지입니다", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + } } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt new file mode 100644 index 0000000..bac4c38 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt @@ -0,0 +1,128 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.domain.model.quotation.QuotationListItem +import com.autoever.everp.domain.model.quotation.QuotationListParams +import com.autoever.everp.domain.model.quotation.QuotationSearchTypeEnum +import com.autoever.everp.domain.model.quotation.QuotationStatusEnum +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class CustomerQuotationViewModel @Inject constructor( + private val sdRepository: SdRepository, +) : ViewModel() { + + // 로딩/에러 상태만 관리 + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> + get() = _uiState.asStateFlow() + + // 실제 리스트는 별도로 누적 관리 + private val _quotationList = MutableStateFlow>(emptyList()) + val quotationList: StateFlow> + get() = _quotationList.asStateFlow() + + private val _totalPages = MutableStateFlow(0) + val totalPages: StateFlow + get() = _totalPages.asStateFlow() + + private val _hasMore = MutableStateFlow(true) + val hasMore: StateFlow + get() = _hasMore.asStateFlow() + + private val _searchParams = MutableStateFlow( + QuotationListParams( + startDate = null, + endDate = null, + status = QuotationStatusEnum.UNKNOWN, + type = QuotationSearchTypeEnum.UNKNOWN, + search = "", + sort = "", + page = 0, + size = 20, + ) + ) + val searchParams: StateFlow + get() = _searchParams.asStateFlow() + + init { + loadQuotations() + } + + fun loadQuotations(append: Boolean = false) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + + sdRepository.refreshQuotationList(searchParams.value) + .onSuccess { + // refresh 후 get을 통해 최신 데이터 가져오기 + sdRepository.getQuotationList(searchParams.value) + .onSuccess { pageResponse -> + if (append) { + // 페이지네이션: 기존 리스트에 추가 + _quotationList.value = _quotationList.value + pageResponse.content + } else { + // 새로운 검색: 리스트 교체 + _quotationList.value = pageResponse.content + } + _totalPages.value = pageResponse.page.totalPages + _hasMore.value = !pageResponse.page.hasNext + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "견적서 목록 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "견적서 목록 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun loadNextPage() { + if (_uiState.value is UiResult.Loading || !_hasMore.value) return + + _searchParams.value = _searchParams.value.copy( + page = _searchParams.value.page + 1 + ) + loadQuotations(append = true) + } + + fun updateSearchQuery( + query: String, + queryType: QuotationSearchTypeEnum = QuotationSearchTypeEnum.UNKNOWN, + ) { + _searchParams.value = _searchParams.value.copy( + search = query, + type = queryType, + page = 0 // 검색 시 페이지 초기화 + ) + } + + fun search() { + loadQuotations(append = false) // 새로운 검색 + } + + fun retry() { + loadQuotations(append = false) + } + + fun refresh() { + _searchParams.value = _searchParams.value.copy(page = 0) + _quotationList.value = emptyList() + loadQuotations(append = false) + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherScreen.kt index 50c299d..22cda0c 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherScreen.kt @@ -1,10 +1,158 @@ package com.autoever.everp.ui.customer +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.ListCard +import com.autoever.everp.ui.common.components.SearchBar +import com.autoever.everp.ui.common.components.StatusBadge +import java.text.NumberFormat +import java.util.Locale @Composable -fun CustomerVoucherScreen() { - // 고객용 바우처 화면 UI 구현 - Text("Customer Voucher Screen") +fun CustomerVoucherScreen( + navController: NavController, + viewModel: CustomerVoucherViewModel = hiltViewModel(), +) { + val invoiceList by viewModel.invoiceList.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() +// val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 헤더 + Text( + text = "매입전표 관리", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + // 검색 바 + SearchBar( + query = searchQuery, + onQueryChange = { viewModel.updateSearchQuery(it) }, + placeholder = "전표번호, 내용, 거래처, 참조번호로 검색", + onSearch = { viewModel.search() }, + ) + +// // 전체 선택 체크박스 +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp, vertical = 8.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// Checkbox( +// checked = selectedInvoiceIds.size == invoiceList.content.size && invoiceList.content.isNotEmpty(), +// onCheckedChange = { +// if (it) { +// viewModel.selectAll() +// } else { +// viewModel.clearSelection() +// } +// }, +// ) +// Text( +// text = "전체 선택", +// style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, +// modifier = Modifier.padding(start = 8.dp), +// ) +// } + + // 리스트 + if (isLoading) { + Text( + text = "로딩 중...", + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(invoiceList.content) { invoice -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { +// Checkbox( +// checked = selectedInvoiceIds.contains(invoice.id), +// onCheckedChange = { viewModel.toggleInvoiceSelection(invoice.id) }, +// modifier = Modifier.padding(start = 8.dp), +// ) + ListCard( + id = invoice.number, + title = invoice.connection.name, + statusBadge = { + StatusBadge( + text = invoice.status.displayName(), + color = invoice.status.toColor(), + ) + }, + details = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "내용: ${invoice.connection.name}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "거래처: ${invoice.connection.name}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "금액: ${formatCurrency(invoice.totalAmount)}원", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + Text( + text = "전표 발생일: ${invoice.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + Text( + text = "만기일: ${invoice.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + Text( + text = "참조번호: ${invoice.reference.number}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + }, + onClick = { + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = invoice.id + ), + ) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) } diff --git a/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt new file mode 100644 index 0000000..15d2719 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt @@ -0,0 +1,100 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.domain.model.invoice.InvoiceListItem +import com.autoever.everp.domain.model.invoice.InvoiceListParams +import com.autoever.everp.domain.repository.FcmRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerVoucherViewModel @Inject constructor( + private val fcmRepository: FcmRepository, +) : ViewModel() { + + private val _invoiceList = MutableStateFlow>( + PageResponse.empty(), + ) + val invoiceList: StateFlow> + get() = _invoiceList.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow + get() = _searchQuery.asStateFlow() + +// private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) +// val selectedInvoiceIds: StateFlow> +// get() = _selectedInvoiceIds.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow + get() = _isLoading.asStateFlow() + + init { + loadInvoices() + } + + fun loadInvoices() { + viewModelScope.launch { + _isLoading.value = true + try { + // 고객사는 매입전표(AP)를 조회 + fcmRepository.refreshApInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { + fcmRepository.getApInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { pageResponse -> + _invoiceList.value = pageResponse + }.onFailure { e -> + Timber.e(e, "매입전표 목록 조회 실패") + } + }.onFailure { e -> + Timber.e(e, "매입전표 목록 로드 실패") + } + } catch (e: Exception) { + Timber.e(e, "매입전표 목록 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } + + fun search() { + loadInvoices() + } + +// fun toggleInvoiceSelection(invoiceId: String) { +// _selectedInvoiceIds.value = if (_selectedInvoiceIds.value.contains(invoiceId)) { +// _selectedInvoiceIds.value - invoiceId +// } else { +// _selectedInvoiceIds.value + invoiceId +// } +// } +// +// fun selectAll() { +// _selectedInvoiceIds.value = _invoiceList.value.content.map { it.id }.toSet() +// } +// +// fun clearSelection() { +// _selectedInvoiceIds.value = emptySet() +// } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt new file mode 100644 index 0000000..0233993 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt @@ -0,0 +1,384 @@ +package com.autoever.everp.ui.customer + +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.Spacer +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.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import com.autoever.everp.domain.model.invoice.InvoiceStatusEnum +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.text.NumberFormat +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun InvoiceDetailScreen( + navController: NavController, + invoiceId: String, + isAp: Boolean = false, // false면 AR(매출), true면 AP(매입) + viewModel: InvoiceDetailViewModel = hiltViewModel(), +) { + val invoiceDetail by viewModel.invoiceDetail.collectAsState() + val uiState by viewModel.uiState.collectAsState() + val requestResult by viewModel.requestResult.collectAsState() + + LaunchedEffect(invoiceId, isAp) { + viewModel.loadInvoiceDetail(invoiceId, isAp) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // 헤더 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + ) + } + Text( + text = "전표 상세", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + when (uiState) { + is UiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.retry(invoiceId, isAp) }) { + Text("다시 시도") + } + } + } + + is UiResult.Success -> { + invoiceDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 전표 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detail.number, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + StatusBadge( + text = detail.status.displayName(), + color = detail.status.toColor(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + DetailRow( + label = "전표유형", + value = detail.type.displayName(), + ) + DetailRow( + label = "거래처", + value = detail.connectionName, + ) + DetailRow( + label = "전표 발생일", + value = detail.issueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "만기일", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "참조번호", + value = detail.referenceNumber, + ) + if (detail.note.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + DetailRow( + label = "메모", + value = detail.note, + ) + } + } + } + + // 주문 품목 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "주문 품목", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + // 테이블 헤더 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "품목", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(2f), + ) + Text( + text = "수량", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "단가", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "금액", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // 테이블 아이템 + detail.items.forEach { item -> + InvoiceItemRow(item = item) + Divider(modifier = Modifier.padding(vertical = 8.dp)) + } + + // 총 금액 + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + // 납부 확인 요청 버튼 (UNPAID 상태일 때만 표시) + if (!isAp && detail.status == InvoiceStatusEnum.UNPAID) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.requestReceivable(invoiceId) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("납부 확인 요청") + } + } + } + } + } + + else -> {} + } + + // 결과 모달 다이얼로그 + requestResult?.let { result -> + AlertDialog( + onDismissRequest = { + viewModel.clearRequestResult() + if (result.isSuccess) { + navController.popBackStack() + } + }, + title = { + Text( + if (result.isSuccess) "요청 완료" else "요청 실패", + ) + }, + text = { + Text( + if (result.isSuccess) { + "납부 확인 요청이 완료되었습니다." + } else { + result.exceptionOrNull()?.message ?: "요청 처리 중 오류가 발생했습니다." + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.clearRequestResult() + if (result.isSuccess) { + navController.popBackStack() + } + }, + ) { + Text("확인") + } + }, + ) + } + } +} + +@Composable +private fun InvoiceItemRow(item: com.autoever.everp.domain.model.invoice.InvoiceDetail.InvoiceDetailItem) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(2f)) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = item.unitOfMaterialName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "${item.quantity}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = formatCurrency(item.unitPrice), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = "${formatCurrency(item.totalPrice)}원", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + valueFontWeight: FontWeight = FontWeight.Normal, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + fontWeight = valueFontWeight, + ) + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt new file mode 100644 index 0000000..3d12474 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt @@ -0,0 +1,84 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.invoice.InvoiceDetail +import com.autoever.everp.domain.repository.FcmRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class InvoiceDetailViewModel @Inject constructor( + private val fcmRepository: FcmRepository, +) : ViewModel() { + + private val _invoiceDetail = MutableStateFlow(null) + val invoiceDetail: StateFlow = _invoiceDetail.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + private val _requestResult = MutableStateFlow?>(null) + val requestResult: StateFlow?> = _requestResult.asStateFlow() + + fun loadInvoiceDetail(invoiceId: String, isAp: Boolean) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + if (isAp) { + fcmRepository.refreshApInvoiceDetail(invoiceId) + .onSuccess { + fcmRepository.getApInvoiceDetail(invoiceId) + .onSuccess { detail -> + _invoiceDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } else { + fcmRepository.refreshArInvoiceDetail(invoiceId) + .onSuccess { + fcmRepository.getArInvoiceDetail(invoiceId) + .onSuccess { detail -> + _invoiceDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + } + } + + fun retry(invoiceId: String, isAp: Boolean) { + loadInvoiceDetail(invoiceId, isAp) + } + + fun requestReceivable(invoiceId: String) { + viewModelScope.launch { + _requestResult.value = fcmRepository.requestReceivable(invoiceId) + .onSuccess { + // 성공 시 상세 정보 다시 로드 + loadInvoiceDetail(invoiceId, false) // AR 인보이스 + } + } + } + + fun clearRequestResult() { + _requestResult.value = null + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt new file mode 100644 index 0000000..0495ece --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt @@ -0,0 +1,277 @@ +package com.autoever.everp.ui.customer + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.autoever.everp.domain.model.notification.Notification +import com.autoever.everp.domain.model.notification.NotificationLinkEnum +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.ui.supplier.SupplierSubNavigationItem +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationScreen( + navController: NavController, + viewModel: NotificationViewModel = hiltViewModel(), +) { + val notifications by viewModel.notifications.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadNotifications() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("알림") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + error != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = error ?: "오류가 발생했습니다.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + notifications.content.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Text( + text = "알림이 없습니다.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(notifications.content) { notification -> + NotificationItem( + notification = notification, + onClick = { + // 알림 클릭 시 읽음 처리 및 상세 화면 이동 + if (!notification.isRead) viewModel.markAsRead(notification.id) + if (notification.linkType.isCustomerRelated()) { + navigateToDetail(navController, notification) + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun NotificationItem( + notification: Notification, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (notification.isRead) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = notification.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, + ) + + StatusBadge( + text = notification.source.toKorean(), + color = notification.source.toColor(), + ) + } + + Text( + text = notification.message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = formatRelativeTime(notification.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun navigateToDetail( + navController: NavController, + notification: Notification, +) { + if (!notification.isNavigable || notification.linkId == null) { + return + } + + when (notification.linkType) { + NotificationLinkEnum.QUOTATION -> { + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(notification.linkId), + ) + } + + NotificationLinkEnum.SALES_ORDER -> { + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(notification.linkId), + ) + } + + NotificationLinkEnum.SALES_INVOICE -> { + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = notification.linkId, + isAp = true, + ), + ) + } + + NotificationLinkEnum.PURCHASE_INVOICE -> { + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = notification.linkId, + isAp = false, + ), + ) + } + + NotificationLinkEnum.PURCHASE_ORDER -> { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(notification.linkId), + ) + } + + else -> { + // 기타 알림은 화면 이동 없음 + } + } +} + +private fun formatRelativeTime(createdAt: LocalDateTime): String { + val now = LocalDateTime.now() + val duration = Duration.between(createdAt, now) + + return when { + duration.toSeconds() < 60 -> { + "${duration.toSeconds()}초 전" + } + + duration.toMinutes() < 60 -> { + "${duration.toMinutes()}분 전" + } + + duration.toHours() < 24 -> { + "${duration.toHours()}시간 전" + } + + duration.toDays() < 30 -> { + "${duration.toDays()}일 전" + } + + ChronoUnit.MONTHS.between(createdAt, now) < 12 -> { + "${ChronoUnit.MONTHS.between(createdAt, now)}개월 전" + } + + else -> { + "${ChronoUnit.YEARS.between(createdAt, now)}년 전" + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/NotificationViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/NotificationViewModel.kt new file mode 100644 index 0000000..f281c0b --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationViewModel.kt @@ -0,0 +1,106 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.domain.model.notification.Notification +import com.autoever.everp.domain.model.notification.NotificationListParams +import com.autoever.everp.domain.model.notification.NotificationSourceEnum +import com.autoever.everp.domain.model.notification.NotificationStatusEnum +import com.autoever.everp.domain.repository.AlarmRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val alarmRepository: AlarmRepository, +) : ViewModel() { + + private val _notifications = MutableStateFlow>(PageResponse.empty()) + val notifications: StateFlow> = _notifications.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + observeNotifications() + loadNotifications() + } + + private fun observeNotifications() { + alarmRepository.observeNotifications() + .onEach { page -> + _notifications.value = page + } + .launchIn(viewModelScope) + } + + fun loadNotifications( + sortBy: String = "createdAt", + order: String = "desc", + source: NotificationSourceEnum = NotificationSourceEnum.UNKNOWN, + page: Int = 0, + size: Int = 20, + ) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + try { + val params = NotificationListParams( + sortBy = sortBy, + order = order, + source = source, + page = page, + size = size, + ) + alarmRepository.refreshNotifications(params) + .onFailure { e -> + Timber.e(e, "알림 목록 로드 실패") + _error.value = "알림 목록을 불러오는데 실패했습니다." + } + } catch (e: Exception) { + Timber.e(e, "알림 목록 로드 중 예외 발생") + _error.value = "알림 목록을 불러오는데 실패했습니다." + } finally { + _isLoading.value = false + } + } + } + + fun markAsRead(notificationId: String) { + viewModelScope.launch { + alarmRepository.markNotificationAsRead(notificationId) + .onFailure { e -> + Timber.e(e, "알림 읽음 처리 실패") + } + } + } + + fun markAllAsRead() { + viewModelScope.launch { + alarmRepository.markAllNotificationsAsRead() + .onSuccess { + // 성공 시 알림 목록 다시 로드 + loadNotifications() + } + .onFailure { e -> + Timber.e(e, "전체 알림 읽음 처리 실패") + } + } + } + + fun refresh() { + loadNotifications() + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt new file mode 100644 index 0000000..1b03895 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt @@ -0,0 +1,369 @@ +package com.autoever.everp.ui.customer + +import android.app.DatePickerDialog +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.navigation.NavController +import com.autoever.everp.utils.state.UiResult +import timber.log.Timber +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuotationCreateScreen( + navController: NavController, + viewModel: QuotationCreateViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + val items by viewModel.items.collectAsState() + val selected by viewModel.selected.collectAsState() + val dueDate by viewModel.dueDate.collectAsState() + val note by viewModel.note.collectAsState() + val totalAmount = viewModel.totalAmount + + var showItemDropdown by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + LaunchedEffect(items) { + Timber.tag("QuotationCreateScreen").d("Loaded items: $items") + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + Text( + text = "견적 요청", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 납기일 선택 + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "납기일", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + val context = LocalContext.current + val currentDate = dueDate ?: LocalDate.now() + val datePickerDialog = remember(dueDate) { + DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + viewModel.setDueDate(LocalDate.of(year, month + 1, dayOfMonth)) + showDatePicker = false + }, + currentDate.year, + currentDate.monthValue - 1, + currentDate.dayOfMonth, + ) + } + + OutlinedTextField( + value = dueDate?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("납기일 선택") }, + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + trailingIcon = { + Row { + if (dueDate != null) { + IconButton( + onClick = { + viewModel.setDueDate(null) + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "날짜 삭제", + tint = Color.Gray, + ) + } + } + IconButton( + onClick = { showDatePicker = true }, + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = "날짜 선택", + ) + } + } + }, + ) + if (showDatePicker) { + datePickerDialog.show() + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 품목 선택 드롭다운 + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "품목 선택", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + ExposedDropdownMenuBox( + expanded = showItemDropdown, + onExpandedChange = { showItemDropdown = it }, + ) { + OutlinedTextField( + value = "", + onValueChange = {}, + readOnly = true, + label = { Text("품목을 선택하세요") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = showItemDropdown) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + ) + ExposedDropdownMenu( + expanded = showItemDropdown, + onDismissRequest = { showItemDropdown = false }, + ) { + if (items.isEmpty()) { + DropdownMenuItem( + text = { Text("품목이 없습니다") }, + onClick = { showItemDropdown = false }, + ) + } else { + items.forEach { item -> + DropdownMenuItem( + text = { + Text("${item.itemName} (${item.uomName}) - ${formatCurrency(item.unitPrice)}원") + }, + onClick = { + viewModel.addItem(item) + showItemDropdown = false + }, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 선택된 품목 목록 + if (selected.isNotEmpty()) { + Text( + text = "선택된 품목", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(selected.values.toList()) { selectedItem -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color.White, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedItem.item.itemName, + style = androidx.compose.material3.MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "단위: ${selectedItem.item.uomName}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + Text( + text = "단가: ${formatCurrency(selectedItem.unitPrice)}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + IconButton(onClick = { viewModel.removeItem(selectedItem.item.itemId) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "삭제", + tint = Color.Red, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { + viewModel.updateQuantity( + selectedItem.item.itemId, + selectedItem.quantity - 1 + ) + }, + enabled = selectedItem.quantity > 1, + ) { + Text("-") + } + Text( + text = "${selectedItem.quantity}", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + TextButton( + onClick = { + viewModel.updateQuantity( + selectedItem.item.itemId, + selectedItem.quantity + 1 + ) + }, + ) { + Text("+") + } + } + Text( + text = "합계: ${formatCurrency(selectedItem.totalPrice)}", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 총 금액 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = androidx.compose.material3.MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = formatCurrency(totalAmount), + style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 비고 + OutlinedTextField( + value = note, + onValueChange = { viewModel.note.value = it }, + label = { Text("비고") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 제출 버튼 + Button( + onClick = { + viewModel.submit { success -> + if (success) navController.popBackStack() + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = selected.isNotEmpty() && uiState !is UiResult.Loading, + ) { + if (uiState is UiResult.Loading) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier + .width(20.dp) + .height(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("견적 검토 요청") + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt new file mode 100644 index 0000000..b9d048f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt @@ -0,0 +1,165 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.inventory.InventoryItemToggle +import com.autoever.everp.domain.model.quotation.QuotationCreateRequest +import com.autoever.everp.domain.repository.ImRepository +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class QuotationCreateViewModel @Inject constructor( + private val imRepository: ImRepository, + private val sdRepository: SdRepository, +) : ViewModel() { + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + // 폼 상태 + val dueDate = MutableStateFlow(null) + val note = MutableStateFlow("") + + // 선택된 품목: itemId -> SelectedItem + data class SelectedItem( + val item: InventoryItemToggle, + val quantity: Int, + val unitPrice: Long, + ) { + val totalPrice: Long get() = quantity * unitPrice + } + + private val _selected = MutableStateFlow>(emptyMap()) + val selected: StateFlow> = _selected.asStateFlow() + + // 선택된 항목들의 총 금액 + val totalAmount: Long + get() = _selected.value.values.sumOf { it.totalPrice } + + init { + // Flow에서 items 업데이트를 먼저 구독 + imRepository.observeItemToggleList() + .onEach { itemList -> + _items.value = itemList + // 데이터가 로드되면 UI 상태 업데이트 + if (itemList.isNotEmpty() && _uiState.value is UiResult.Loading) { + _uiState.value = UiResult.Success(Unit) + } + } + .launchIn(viewModelScope) + + // 초기 데이터 로드 + loadItems() + } + + fun loadItems() { + viewModelScope.launch { + _uiState.value = UiResult.Loading + imRepository.refreshItemToggleList() + .onSuccess { + // refresh 성공 후 observeItemToggleList()를 통해 자동으로 업데이트됨 + // 하지만 데이터가 없을 수도 있으므로 확인 + val currentItems = _items.value + if (currentItems.isEmpty()) { + // 직접 조회해서 확인 + imRepository.getItemToggleList() + .onSuccess { items -> + _items.value = items + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } else { + _uiState.value = UiResult.Success(Unit) + } + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun addItem(item: InventoryItemToggle) { + _selected.value = _selected.value.toMutableMap().apply { + put( + item.itemId, + SelectedItem( + item = item, + quantity = 1, + unitPrice = item.unitPrice, + ), + ) + } + } + + fun removeItem(itemId: String) { + _selected.value = _selected.value.toMutableMap().apply { + remove(itemId) + } + } + + fun updateQuantity(itemId: String, quantity: Int) { + val selectedItem = _selected.value[itemId] ?: return + if (quantity <= 0) { + removeItem(itemId) + } else { + _selected.value = _selected.value.toMutableMap().apply { + put( + itemId, + selectedItem.copy(quantity = quantity), + ) + } + } + } + + fun setDueDate(date: LocalDate?) { + dueDate.value = date + } + + fun submit(onDone: (Boolean) -> Unit) { + viewModelScope.launch { + if (_selected.value.isEmpty()) { + _uiState.value = UiResult.Error(Exception("품목을 선택해주세요.")) + onDone(false) + return@launch + } + + _uiState.value = UiResult.Loading + val request = QuotationCreateRequest( + dueDate = dueDate.value, + items = _selected.value.map { (_, selectedItem) -> + QuotationCreateRequest.QuotationCreateRequestItem( + id = selectedItem.item.itemId, + quantity = selectedItem.quantity, + unitPrice = selectedItem.unitPrice, + ) + }, + note = note.value, + ) + sdRepository.createQuotation(request) + .onSuccess { + _uiState.value = UiResult.Success(Unit) + onDone(true) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + onDone(false) + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt new file mode 100644 index 0000000..6e02e8d --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt @@ -0,0 +1,335 @@ +package com.autoever.everp.ui.customer + +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.Spacer +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.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.text.NumberFormat +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun QuotationDetailScreen( + navController: NavController, + quotationId: String, + viewModel: QuotationDetailViewModel = hiltViewModel(), +) { + val quotationDetail by viewModel.quotationDetail.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(quotationId) { + viewModel.loadQuotationDetail(quotationId) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // 헤더 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + ) + } + Text( + text = "견적서 상세", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + when (uiState) { + is UiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.retry(quotationId) }) { + Text("다시 시도") + } + } + } + + is UiResult.Success -> { + quotationDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 견적서 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detail.number, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + StatusBadge( + text = detail.status.displayName(), + color = detail.status.toColor(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + DetailRow( + label = "견적일자", + value = detail.issueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "납기일자", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "총 금액", + value = "${formatCurrency(detail.totalAmount)}원", + valueColor = MaterialTheme.colorScheme.primary, + valueFontWeight = FontWeight.Bold, + ) + } + } + + // 고객 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "고객 정보", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + DetailRow( + label = "고객명", + value = detail.customer.name, + ) + DetailRow( + label = "담당자", + value = detail.customer.ceoName, + ) + } + } + + // 견적 품목 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "견적 품목", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + detail.items.forEach { item -> + QuotationItemRow(item = item) + Spacer(modifier = Modifier.height(16.dp)) + } + + // 총 금액 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + // 비고 카드 (이미지에 비고가 있으므로 추가) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "비고", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "긴급 주문으로 빠른 납기 요청드립니다.", // TODO: 실제 데이터에서 가져오기 + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } + + else -> {} + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + valueFontWeight: FontWeight = FontWeight.Normal, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + fontWeight = valueFontWeight, + ) + } +} + +@Composable +private fun QuotationItemRow(item: com.autoever.everp.domain.model.quotation.QuotationDetail.QuotationDetailItem) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "수량: ${item.quantity}${item.uomName}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "단가: ${formatCurrency(item.unitPrice)}원", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "${formatCurrency(item.totalPrice)}원", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailViewModel.kt new file mode 100644 index 0000000..6715442 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailViewModel.kt @@ -0,0 +1,50 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.quotation.QuotationDetail +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class QuotationDetailViewModel @Inject constructor( + private val sdRepository: SdRepository, +) : ViewModel() { + + private val _quotationDetail = MutableStateFlow(null) + val quotationDetail: StateFlow = _quotationDetail.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + fun loadQuotationDetail(quotationId: String) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + sdRepository.refreshQuotationDetail(quotationId) + .onSuccess { + sdRepository.getQuotationDetail(quotationId) + .onSuccess { detail -> + _quotationDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun retry(quotationId: String) { + loadQuotationDetail(quotationId) + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailScreen.kt new file mode 100644 index 0000000..81d8e70 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailScreen.kt @@ -0,0 +1,410 @@ +package com.autoever.everp.ui.customer + +import androidx.compose.foundation.background +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.Spacer +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.layout.size +import androidx.compose.foundation.layout.width +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.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.text.NumberFormat +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun SalesOrderDetailScreen( + navController: NavController, + salesOrderId: String, + viewModel: SalesOrderDetailViewModel = hiltViewModel(), +) { + val salesOrderDetail by viewModel.salesOrderDetail.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(salesOrderId) { + viewModel.loadSalesOrderDetail(salesOrderId) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // 헤더 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "뒤로가기", + ) + } + Text( + text = "주문 상세", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + when (uiState) { + is UiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.retry(salesOrderId) }) { + Text("다시 시도") + } + } + } + + is UiResult.Success -> { + salesOrderDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 주문 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = detail.salesOrderNumber, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + StatusBadge( + text = detail.statusCode.displayName(), + color = detail.statusCode.toColor(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + DetailRow( + label = "주문일자", + value = detail.orderDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "납기일", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "주문금액", + value = "${formatCurrency(detail.totalAmount)}원", + valueColor = MaterialTheme.colorScheme.primary, + valueFontWeight = FontWeight.Bold, + ) + // 운송장번호는 현재 데이터 모델에 없음 (추후 추가 가능) + } + } + + // 주문 진행 상태 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "주문 진행 상태", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + val statuses = com.autoever.everp.domain.model.sale.SalesOrderStatusEnum.entries + .filter { it != com.autoever.everp.domain.model.sale.SalesOrderStatusEnum.UNKNOWN } + val currentStatusIndex = statuses.indexOf(detail.statusCode) + + statuses.forEachIndexed { index, status -> + OrderStatusItem( + status = status, + isCompleted = index <= currentStatusIndex, + ) + if (index < statuses.size - 1) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + // 고객 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "고객 정보", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + DetailRow( + label = "고객명", + value = detail.customerName, + ) + DetailRow( + label = "담당자", + value = detail.managerName, + ) + DetailRow( + label = "이메일", + value = detail.managerEmail, + ) + } + } + + // 배송 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "배송 정보", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + DetailRow( + label = "배송지", + value = detail.fullAddress, + ) + } + } + + // 주문 품목 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "주문 품목", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + detail.items.forEach { item -> + OrderItemRow(item = item) + Spacer(modifier = Modifier.height(16.dp)) + } + + // 총 금액 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + else -> {} + } + } +} + +@Composable +private fun OrderStatusItem( + status: com.autoever.everp.domain.model.sale.SalesOrderStatusEnum, + isCompleted: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background( + if (isCompleted) MaterialTheme.colorScheme.primary + else Color(0xFF9E9E9E), + ), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = status.displayName(), + style = MaterialTheme.typography.bodyMedium, + color = if (isCompleted) MaterialTheme.colorScheme.onSurface + else Color(0xFF9E9E9E), + ) + } +} + +@Composable +private fun OrderItemRow(item: com.autoever.everp.domain.model.sale.SalesOrderItem) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = item.itemName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "수량: ${item.quantity}개", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "단가: ${formatCurrency(item.unitPrice)}원", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "${formatCurrency(item.totalPrice)}원", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + modifier: Modifier = Modifier, + valueColor: Color = MaterialTheme.colorScheme.onSurface, + valueFontWeight: FontWeight = FontWeight.Normal, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + fontWeight = valueFontWeight, + ) + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailViewModel.kt new file mode 100644 index 0000000..67c8c17 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailViewModel.kt @@ -0,0 +1,50 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.sale.SalesOrderDetail +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SalesOrderDetailViewModel @Inject constructor( + private val sdRepository: SdRepository, +) : ViewModel() { + + private val _salesOrderDetail = MutableStateFlow(null) + val salesOrderDetail: StateFlow = _salesOrderDetail.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + fun loadSalesOrderDetail(salesOrderId: String) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + sdRepository.refreshSalesOrderDetail(salesOrderId) + .onSuccess { + sdRepository.getSalesOrderDetail(salesOrderId) + .onSuccess { detail -> + _salesOrderDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun retry(salesOrderId: String) { + loadSalesOrderDetail(salesOrderId) + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt index d5dade0..72587a5 100644 --- a/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt @@ -1,23 +1,29 @@ package com.autoever.everp.ui.home -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.autoever.everp.auth.model.UserInfo +import com.autoever.everp.auth.repository.UserRepository import com.autoever.everp.auth.session.AuthState import com.autoever.everp.auth.session.SessionManager -import com.autoever.everp.auth.repository.UserRepository -import com.autoever.everp.auth.model.UserInfo -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import com.autoever.everp.common.error.UnauthorizedException +import com.autoever.everp.domain.repository.AlarmRepository +import com.autoever.everp.domain.repository.DeviceInfoRepository +import com.autoever.everp.domain.repository.PushNotificationRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val sessionManager: SessionManager, private val userRepository: UserRepository, + private val deviceInfoRepository: DeviceInfoRepository, + private val pushNotificationRepository: PushNotificationRepository, + private val alarmRepository: AlarmRepository, ) : ViewModel() { val authState: StateFlow = sessionManager.state @@ -31,8 +37,7 @@ class HomeViewModel @Inject constructor( try { val info = userRepository.fetchUserInfo(st.accessToken) _user.value = info - Log.i( - TAG, + Timber.tag(TAG).i( "[INFO] 사용자 정보 로딩 완료 | " + "userId=${info.userId ?: "null"}, " + "userName=${info.userName ?: "null"}, " + @@ -40,11 +45,13 @@ class HomeViewModel @Inject constructor( "role=${info.userRole ?: "null"}, " + "userType=${info.userType ?: "null"}" ) + // 사용자 정보 로드 성공 후 FCM 토큰 등록 + registerFcmToken() } catch (e: UnauthorizedException) { - Log.w(TAG, "[WARN] 인증 만료로 로그아웃 처리") + Timber.tag(TAG).w("[WARN] 인증 만료로 로그아웃 처리") sessionManager.signOut() } catch (e: Exception) { - Log.e(TAG, "[ERROR] 사용자 정보 로드 실패: ${e.message}") + Timber.tag(TAG).e("[ERROR] 사용자 정보 로드 실패: ${e.message}") } } } else { @@ -52,6 +59,32 @@ class HomeViewModel @Inject constructor( } } + /** + * FCM 토큰을 가져와서 서버에 등록합니다. + */ + private suspend fun registerFcmToken() { + try { + // 1. FCM 토큰 가져오기 + val fcmToken = pushNotificationRepository.getToken() + Timber.tag(TAG).d("[INFO] FCM 토큰 획득 성공") + + // 2. Android ID 가져오기 + val androidId = deviceInfoRepository.getAndroidId() + Timber.tag(TAG).d("[INFO] Android ID 획득: $androidId") + + // 3. 서버에 FCM 토큰 등록 + alarmRepository.registerFcmToken( + token = fcmToken, + deviceId = androidId, + deviceType = "ANDROID", + ) + Timber.tag(TAG).i("[INFO] FCM 토큰 서버 등록 완료") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "[ERROR] FCM 토큰 등록 실패: ${e.message}") + // FCM 토큰 등록 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + } + } + companion object { private const val TAG = "HomeViewModel" } diff --git a/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt b/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt index 6188791..639d6e1 100644 --- a/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/main/MainScreen.kt @@ -31,9 +31,14 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.ExperimentalFoundationApi import kotlinx.coroutines.launch import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.autoever.everp.ui.home.HomeViewModel import com.autoever.everp.auth.session.AuthState +import com.autoever.everp.domain.model.user.UserTypeEnum +import com.autoever.everp.ui.customer.CustomerApp +import com.autoever.everp.ui.login.LoginScreen import com.autoever.everp.ui.navigation.Routes +import com.autoever.everp.ui.supplier.SupplierApp import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.collect @@ -49,11 +54,13 @@ data class BottomNavItem(val route: String, val label: String) @Composable fun MainScreen( - appNavController: NavController, + appNavController: NavHostController, ) { // Auth guard: if unauthenticated, navigate to LOGIN val homeVm: HomeViewModel = hiltViewModel() val authStateFlow = homeVm.authState + val userInfo by homeVm.user.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { authStateFlow .onEach { st -> @@ -66,47 +73,62 @@ fun MainScreen( } .collect() } - val tabs = listOf( - BottomNavItem(TabRoutes.HOME, "홈"), - BottomNavItem(TabRoutes.ORDERS, "주문서"), - BottomNavItem(TabRoutes.QUOTES, "견적서"), - BottomNavItem(TabRoutes.VOUCHERS, "전표"), - BottomNavItem(TabRoutes.PROFILE, "프로필"), - ) - val tabNavController: NavHostController = rememberNavController() - var selectedRoute by rememberSaveable { mutableStateOf(tabs.first().route) } - - Scaffold( - bottomBar = { - NavigationBar { - tabs.forEach { item -> - val selected = selectedRoute == item.route - NavigationBarItem( - selected = selected, - onClick = { - selectedRoute = item.route - if (tabNavController.currentDestination?.route != item.route) { - tabNavController.navigate(item.route) { - popUpTo(tabNavController.graph.startDestinationId) { saveState = true } - launchSingleTop = true - restoreState = true + + + when (UserTypeEnum.fromStringOrDefault(userInfo?.userType ?: "")) { + UserTypeEnum.CUSTOMER -> CustomerApp(appNavController) + UserTypeEnum.SUPPLIER -> SupplierApp(appNavController) + else -> CustomerApp(appNavController) // TODO 임시로 고객사 앱으로 연결 나중에는 연결 안되게 처리 + } + /* + // TODO + val tabs = listOf( + BottomNavItem(TabRoutes.HOME, "홈"), + BottomNavItem(TabRoutes.ORDERS, "주문서"), + BottomNavItem(TabRoutes.QUOTES, "견적서"), + BottomNavItem(TabRoutes.VOUCHERS, "전표"), + BottomNavItem(TabRoutes.PROFILE, "프로필"), + ) + val tabNavController: NavHostController = rememberNavController() + var selectedRoute by rememberSaveable { mutableStateOf(tabs.first().route) } + + Scaffold( + bottomBar = { + NavigationBar { + tabs.forEach { item -> + val selected = selectedRoute == item.route + NavigationBarItem( + selected = selected, + onClick = { + selectedRoute = item.route + if (tabNavController.currentDestination?.route != item.route) { + tabNavController.navigate(item.route) { + popUpTo(tabNavController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } } - } - }, - icon = { Text(item.label.take(1)) }, - label = { Text(item.label) }, - alwaysShowLabel = true, - ) + }, + icon = { Text(item.label.take(1)) }, + label = { Text(item.label) }, + alwaysShowLabel = true, + ) + } } } + ) { padding -> + TabNavHost(navController = tabNavController, appNavController = appNavController, modifier = Modifier.padding(padding)) } - ) { padding -> - TabNavHost(navController = tabNavController, appNavController = appNavController, modifier = Modifier.padding(padding)) - } + */ + } @Composable -private fun TabNavHost(navController: NavHostController, appNavController: NavController, modifier: Modifier = Modifier) { +private fun TabNavHost( + navController: NavHostController, + appNavController: NavController, + modifier: Modifier = Modifier +) { NavHost( navController = navController, startDestination = TabRoutes.HOME, @@ -121,7 +143,8 @@ private fun TabNavHost(navController: NavHostController, appNavController: NavCo } @OptIn(ExperimentalFoundationApi::class) -@Composable private fun OrdersRootScreen() { +@Composable +private fun OrdersRootScreen() { val tabs = listOf("전체", "진행중", "완료") TopTabsPager(tabs = tabs) { page -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -131,7 +154,8 @@ private fun TabNavHost(navController: NavHostController, appNavController: NavCo } @OptIn(ExperimentalFoundationApi::class) -@Composable private fun QuotesRootScreen() { +@Composable +private fun QuotesRootScreen() { val tabs = listOf("전체", "요청", "승인") TopTabsPager(tabs = tabs) { page -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -141,7 +165,8 @@ private fun TabNavHost(navController: NavHostController, appNavController: NavCo } @OptIn(ExperimentalFoundationApi::class) -@Composable private fun VouchersRootScreen() { +@Composable +private fun VouchersRootScreen() { val tabs = listOf("매입", "매출", "일반분개") TopTabsPager(tabs = tabs) { page -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -150,11 +175,13 @@ private fun TabNavHost(navController: NavHostController, appNavController: NavCo } } -@Composable private fun ProfileRootScreen() { +@Composable +private fun ProfileRootScreen() { com.autoever.everp.ui.profile.ProfileScreen() } -@Composable private fun HomeRootScreen(appNavController: NavController) { +@Composable +private fun HomeRootScreen(appNavController: NavController) { com.autoever.everp.ui.home.HomeScreen(navController = appNavController) } diff --git a/app/src/main/java/com/autoever/everp/ui/navigation/AppNavGraph.kt b/app/src/main/java/com/autoever/everp/ui/navigation/AppNavGraph.kt index 03ca24d..94c10e3 100644 --- a/app/src/main/java/com/autoever/everp/ui/navigation/AppNavGraph.kt +++ b/app/src/main/java/com/autoever/everp/ui/navigation/AppNavGraph.kt @@ -43,21 +43,20 @@ fun AppNavGraph( val homeVm: HomeViewModel = hiltViewModel() val stateFlow = homeVm.authState LaunchedEffect(Unit) { - stateFlow - .onEach { st -> - if (st is AuthState.Authenticated) { - navController.navigate(Routes.HOME) { - popUpTo(Routes.LOGIN) { inclusive = true } - launchSingleTop = true - } + stateFlow.onEach { st -> + if (st is AuthState.Authenticated) { + navController.navigate(Routes.HOME) { + popUpTo(Routes.LOGIN) { inclusive = true } + launchSingleTop = true } } + } .collect() } LoginScreen( onLoginClick = { Timber.tag("AuthFlow").i("[INFO] 로그인 버튼 클릭") - AuthCct.start(ctx) + AuthCct.start(ctx) // TODO 1 } ) } diff --git a/app/src/main/java/com/autoever/everp/ui/navigation/CustomBottomBar.kt b/app/src/main/java/com/autoever/everp/ui/navigation/CustomBottomBar.kt index e2dae23..938dcee 100644 --- a/app/src/main/java/com/autoever/everp/ui/navigation/CustomBottomBar.kt +++ b/app/src/main/java/com/autoever/everp/ui/navigation/CustomBottomBar.kt @@ -41,12 +41,27 @@ fun CustomNavigationBar( NavigationBarItem( selected = isSelected, // (4) selected 상태 전달 onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true + if (isSelected) { + // (A) Behavior 2: 이미 선택된 탭을 또 누름 + // 이 탭의 루트 스크린으로 이동하고, 그 위의 모든 스택을 날림 + navController.popBackStack(screen.route, inclusive = false) + } else { + // (B) Behavior 1: 다른 탭을 누름 + // 먼저 백 스택에 해당 destination이 있는지 확인하고 popBackStack 시도 + val popped = navController.popBackStack(screen.route, inclusive = false) + + if (!popped) { + // 백 스택에 없으면 navigate + // 빠른 동작 버튼으로 이동한 경우를 고려하여 restoreState 사용하지 않음 + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true // 현재 탭의 백 스택 저장 + } + launchSingleTop = true // 중복 화면 방지 + // restoreState를 false로 설정하여 항상 새로운 인스턴스로 이동 + restoreState = false + } } - launchSingleTop = true - restoreState = true } }, // (5) ⭐ 아이콘 동적 변경 diff --git a/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt index 0aee17d..ac4a6a0 100644 --- a/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt @@ -13,6 +13,7 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import timber.log.Timber data class ProfileUiState( val isLoading: Boolean = false, @@ -25,6 +26,7 @@ class ProfileViewModel @Inject constructor( private val sessionManager: SessionManager, private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val user: com.autoever.everp.domain.repository.UserRepository ) : ViewModel() { private val _ui = MutableStateFlow(ProfileUiState(isLoading = true)) @@ -46,7 +48,7 @@ class ProfileViewModel @Inject constructor( val info = userRepository.fetchUserInfo(st.accessToken) _ui.value = ProfileUiState(isLoading = false, user = info) } catch (e: Exception) { - Log.e(TAG, "[ERROR] 프로필 조회 실패: ${e.message}") + Timber.tag(TAG).e("[ERROR] 프로필 조회 실패: ${e.message}") _ui.value = ProfileUiState(isLoading = false, errorMessage = e.message) } } @@ -62,6 +64,7 @@ class ProfileViewModel @Inject constructor( } } finally { sessionManager.signOut() + user.logout() } } } diff --git a/app/src/main/java/com/autoever/everp/ui/redirect/RedirectReceiverActivity.kt b/app/src/main/java/com/autoever/everp/ui/redirect/RedirectReceiverActivity.kt index 379d031..8204cad 100644 --- a/app/src/main/java/com/autoever/everp/ui/redirect/RedirectReceiverActivity.kt +++ b/app/src/main/java/com/autoever/everp/ui/redirect/RedirectReceiverActivity.kt @@ -13,6 +13,7 @@ import com.autoever.everp.auth.flow.AuthFlowMemory import com.autoever.everp.auth.repository.AuthRepository import com.autoever.everp.auth.session.SessionManager import kotlinx.coroutines.launch +import timber.log.Timber /** * OAuth2 리다이렉트를 수신하는 투명 액티비티. @@ -37,7 +38,7 @@ class RedirectReceiverActivity : ComponentActivity() { val expectedState = AuthFlowMemory.state if (config == null || pkce == null || expectedState.isNullOrEmpty()) { - Log.e(TAG, "[ERROR] 인가 플로우 컨텍스트가 없어 토큰 교환을 진행할 수 없습니다.") + Timber.tag(TAG).e("[ERROR] 인가 플로우 컨텍스트가 없어 토큰 교환을 진행할 수 없습니다.") finishToMain() return } @@ -53,7 +54,7 @@ class RedirectReceiverActivity : ComponentActivity() { val code = data.getQueryParameter("code") val state = data.getQueryParameter("state") if (code.isNullOrEmpty() || state.isNullOrEmpty() || state != expectedState) { - Log.e(TAG, "[ERROR] 리다이렉트 파라미터 검증 실패 (code/state)") + Timber.tag(TAG).e("[ERROR] 리다이렉트 파라미터 검증 실패 (code/state)") finishToMain() return } @@ -66,9 +67,9 @@ class RedirectReceiverActivity : ComponentActivity() { config = config, ) sessionManager.setAuthenticated(token.accessToken) - Log.i(TAG, "[INFO] 토큰 교환 및 세션 반영 성공") + Timber.tag(TAG).i("[INFO] 토큰 교환 및 세션 반영 성공") } catch (e: Exception) { - Log.e(TAG, "[ERROR] 토큰 교환 처리 실패: ${e.message}") + Timber.tag(TAG).e("[ERROR] 토큰 교환 처리 실패: ${e.message}") } finally { AuthFlowMemory.clear() finishToMain() diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt new file mode 100644 index 0000000..20d40de --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt @@ -0,0 +1,376 @@ +package com.autoever.everp.ui.supplier + +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.Spacer +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.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +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.Text +import androidx.compose.material3.TextButton +import com.autoever.everp.domain.model.invoice.InvoiceStatusEnum +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.text.NumberFormat +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InvoiceDetailScreen( + navController: NavController, + viewModel: InvoiceDetailViewModel = hiltViewModel(), +) { + val invoiceDetail by viewModel.invoiceDetail.collectAsState() + val uiState by viewModel.uiState.collectAsState() + val completeResult by viewModel.completeResult.collectAsState() + val isAp = viewModel.isAp + + LaunchedEffect(Unit) { + viewModel.loadInvoiceDetail() + } + + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text("전표 상세 정보") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + when (uiState) { + is UiResult.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.loadInvoiceDetail() }) { + Text("다시 시도") + } + } + } + + is UiResult.Success -> { + invoiceDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 전표 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + + DetailRow( + label = "전표번호", + value = detail.number, + ) + DetailRow( + label = "전표유형", + value = detail.type.displayName(), + ) + DetailRow( + label = "거래처", + value = detail.connectionName, + ) + DetailRow( + label = "전표 발생일", + value = detail.issueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "만기일", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "상태", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + StatusBadge( + text = detail.status.displayName(), + color = detail.status.toColor(), + ) + } + DetailRow( + label = "메모", + value = detail.note.ifBlank { "-" }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 주문 품목 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "주문 품목", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + // 테이블 헤더 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "품목", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(2f), + ) + Text( + text = "수량", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "단위", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "단가", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "금액", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + // 테이블 아이템 + detail.items.forEach { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(2f), + ) + Text( + text = "${item.quantity}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = item.unitOfMaterialName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = formatCurrency(item.unitPrice), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = "${formatCurrency(item.totalPrice)}원", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } + Divider(modifier = Modifier.padding(vertical = 4.dp)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 총 금액 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = androidx.compose.ui.graphics.Color(0xFF4CAF50), // Green color + ) + } + } + } + + // 납부 확인 버튼 (PENDING 상태일 때만 표시) + if (!isAp && detail.status == InvoiceStatusEnum.PENDING) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { viewModel.completeReceivable() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("납부 확인") + } + } + } + } + } + + else -> {} + } + + // 결과 모달 다이얼로그 + completeResult?.let { result -> + AlertDialog( + onDismissRequest = { + viewModel.clearCompleteResult() + if (result.isSuccess) { + navController.popBackStack() + } + }, + title = { + Text( + if (result.isSuccess) "처리 완료" else "처리 실패", + ) + }, + text = { + Text( + if (result.isSuccess) { + "납부 확인이 완료되었습니다." + } else { + result.exceptionOrNull()?.message ?: "처리 중 오류가 발생했습니다." + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.clearCompleteResult() + if (result.isSuccess) { + navController.popBackStack() + } + }, + ) { + Text("확인") + } + }, + ) + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +private fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt new file mode 100644 index 0000000..f982e80 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt @@ -0,0 +1,103 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.invoice.InvoiceDetail +import com.autoever.everp.domain.repository.FcmRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class InvoiceDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val fcmRepository: FcmRepository, +) : ViewModel() { + + private val invoiceId: String = savedStateHandle.get( + SupplierSubNavigationItem.InvoiceDetailItem.ARG_ID, + ) ?: "" + + val isAp: Boolean = savedStateHandle.get( + SupplierSubNavigationItem.InvoiceDetailItem.ARG_IS_AP, + ) ?: true + + private val _invoiceDetail = MutableStateFlow(null) + val invoiceDetail: StateFlow = _invoiceDetail.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + private val _completeResult = MutableStateFlow?>(null) + val completeResult: StateFlow?> = _completeResult.asStateFlow() + + init { + if (invoiceId.isNotEmpty()) { + loadInvoiceDetail() + } + } + + fun loadInvoiceDetail() { + viewModelScope.launch { + _uiState.value = UiResult.Loading + + if (isAp) { + fcmRepository.refreshApInvoiceDetail(invoiceId) + .onSuccess { + fcmRepository.getApInvoiceDetail(invoiceId) + .onSuccess { detail -> + _invoiceDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "매입전표 상세 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "매입전표 상세 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } else { + // TODO 지금은 전표 없음으로 변경 + fcmRepository.refreshArInvoiceDetail(invoiceId) + .onSuccess { + fcmRepository.getArInvoiceDetail(invoiceId) + .onSuccess { detail -> + _invoiceDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "매출전표 상세 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "매출전표 상세 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + } + } + + fun completeReceivable() { + viewModelScope.launch { + _completeResult.value = fcmRepository.completeReceivable(invoiceId) + .onSuccess { + // 성공 시 상세 정보 다시 로드 + loadInvoiceDetail() + } + } + } + + fun clearCompleteResult() { + _completeResult.value = null + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt new file mode 100644 index 0000000..5832b98 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt @@ -0,0 +1,256 @@ +package com.autoever.everp.ui.supplier + +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.autoever.everp.domain.model.notification.Notification +import com.autoever.everp.domain.model.notification.NotificationLinkEnum +import com.autoever.everp.ui.common.components.StatusBadge +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationScreen( + navController: NavController, + viewModel: NotificationViewModel = hiltViewModel(), +) { + val notifications by viewModel.notifications.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadNotifications() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("알림") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + error != null -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = error ?: "오류가 발생했습니다.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + notifications.content.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Text( + text = "알림이 없습니다.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(notifications.content) { notification -> + NotificationItem( + notification = notification, + onClick = { + // 알림 클릭 시 읽음 처리 및 상세 화면 이동 + viewModel.markAsRead(notification.id) + navigateToDetail(navController, notification) + }, + ) + } + } + } + } + } +} + +@Composable +private fun NotificationItem( + notification: Notification, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (notification.isRead) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = notification.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, + ) + Text( + text = notification.message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!notification.isRead) { + StatusBadge( + text = "읽지 않음", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatRelativeTime(notification.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StatusBadge( + text = notification.source.toKorean(), + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } +} + +private fun navigateToDetail( + navController: NavController, + notification: Notification, +) { + if (!notification.isNavigable || notification.linkId == null) { + return + } + + when (notification.linkType) { + NotificationLinkEnum.PURCHASE_ORDER -> { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(notification.linkId), + ) + } + NotificationLinkEnum.PURCHASE_INVOICE -> { + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = notification.linkId, + isAp = true, + ), + ) + } + else -> { + // Supplier 화면에서는 발주와 매입 전표만 이동 + } + } +} + +private fun formatRelativeTime(createdAt: LocalDateTime): String { + val now = LocalDateTime.now() + val duration = Duration.between(createdAt, now) + + return when { + duration.toSeconds() < 60 -> { + "${duration.toSeconds()}초 전" + } + duration.toMinutes() < 60 -> { + "${duration.toMinutes()}분 전" + } + duration.toHours() < 24 -> { + "${duration.toHours()}시간 전" + } + duration.toDays() < 30 -> { + "${duration.toDays()}일 전" + } + ChronoUnit.MONTHS.between(createdAt, now) < 12 -> { + "${ChronoUnit.MONTHS.between(createdAt, now)}개월 전" + } + else -> { + "${ChronoUnit.YEARS.between(createdAt, now)}년 전" + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/NotificationViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationViewModel.kt new file mode 100644 index 0000000..6322259 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/NotificationViewModel.kt @@ -0,0 +1,105 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.domain.model.notification.Notification +import com.autoever.everp.domain.model.notification.NotificationListParams +import com.autoever.everp.domain.model.notification.NotificationSourceEnum +import com.autoever.everp.domain.repository.AlarmRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val alarmRepository: AlarmRepository, +) : ViewModel() { + + private val _notifications = MutableStateFlow>(PageResponse.empty()) + val notifications: StateFlow> = _notifications.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + init { + observeNotifications() + loadNotifications() + } + + private fun observeNotifications() { + alarmRepository.observeNotifications() + .onEach { page -> + _notifications.value = page + } + .launchIn(viewModelScope) + } + + fun loadNotifications( + sortBy: String = "createdAt", + order: String = "desc", + source: NotificationSourceEnum = NotificationSourceEnum.UNKNOWN, + page: Int = 0, + size: Int = 20, + ) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + try { + val params = NotificationListParams( + sortBy = sortBy, + order = order, + source = source, + page = page, + size = size, + ) + alarmRepository.refreshNotifications(params) + .onFailure { e -> + Timber.e(e, "알림 목록 로드 실패") + _error.value = "알림 목록을 불러오는데 실패했습니다." + } + } catch (e: Exception) { + Timber.e(e, "알림 목록 로드 중 예외 발생") + _error.value = "알림 목록을 불러오는데 실패했습니다." + } finally { + _isLoading.value = false + } + } + } + + fun markAsRead(notificationId: String) { + viewModelScope.launch { + alarmRepository.markNotificationAsRead(notificationId) + .onFailure { e -> + Timber.e(e, "알림 읽음 처리 실패") + } + } + } + + fun markAllAsRead() { + viewModelScope.launch { + alarmRepository.markAllNotificationsAsRead() + .onSuccess { + // 성공 시 알림 목록 다시 로드 + loadNotifications() + } + .onFailure { e -> + Timber.e(e, "전체 알림 읽음 처리 실패") + } + } + } + + fun refresh() { + loadNotifications() + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt new file mode 100644 index 0000000..c89b45d --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt @@ -0,0 +1,375 @@ +package com.autoever.everp.ui.supplier + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.utils.state.UiResult +import java.text.NumberFormat +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PurchaseOrderDetailScreen( + navController: NavController, + viewModel: PurchaseOrderDetailViewModel = hiltViewModel(), +) { + val purchaseOrderDetail by viewModel.purchaseOrderDetail.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadPurchaseOrderDetail() + } + + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text("발주서 상세 정보") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + when (uiState) { + is UiResult.Loading -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } + + is UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as UiResult.Error).exception.message}", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + is UiResult.Success -> { + purchaseOrderDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 발주서 정보 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "발주서 정보", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + DetailRow( + label = "발주번호", + value = detail.number, + ) + DetailRow( + label = "공급업체", + value = detail.supplier.name, + ) + DetailRow( + label = "연락처", + value = detail.supplier.managerPhone, + ) + DetailRow( + label = "이메일", + value = detail.supplier.managerEmail, + ) + DetailRow( + label = "주문일자", + value = detail.orderDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "납기일", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "상태", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + StatusBadge( + text = detail.status.displayName(), + color = detail.status.toColor(), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 주문 품목 카드 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "주문 품목", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + + // 테이블 헤더 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "품목명", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(2f), + ) + Text( + text = "수량", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "단위", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "단가", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = "금액", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 테이블 구분선 + androidx.compose.material3.Divider() + + Spacer(modifier = Modifier.height(8.dp)) + + // 품목 리스트 + detail.items.forEach { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = item.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(2f), + ) + Text( + text = "${item.quantity}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = item.uomName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = formatCurrency(item.unitPrice), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = formatCurrency(item.totalPrice), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } + androidx.compose.material3.Divider() + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 총 금액 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "총 금액", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatCurrency(detail.totalAmount)}원", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 배송 및 메모 정보 카드 + if (detail.note.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "배송 및 메모", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp), + ) + DetailRow( + label = "메모", + value = detail.note, + ) + } + } + } + } + } + } + + else -> {} + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +private fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailViewModel.kt new file mode 100644 index 0000000..ce92478 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailViewModel.kt @@ -0,0 +1,62 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.purchase.PurchaseOrderDetail +import com.autoever.everp.domain.repository.MmRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class PurchaseOrderDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val mmRepository: MmRepository, +) : ViewModel() { + + private val purchaseOrderId: String = savedStateHandle.get( + SupplierSubNavigationItem.PurchaseOrderDetailItem.ARG_ID, + ) ?: "" + + private val _purchaseOrderDetail = MutableStateFlow(null) + val purchaseOrderDetail: StateFlow = _purchaseOrderDetail.asStateFlow() + + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> = _uiState.asStateFlow() + + init { + if (purchaseOrderId.isNotEmpty()) { + loadPurchaseOrderDetail() + } + } + + fun loadPurchaseOrderDetail() { + viewModelScope.launch { + _uiState.value = UiResult.Loading + + mmRepository.refreshPurchaseOrderDetail(purchaseOrderId) + .onSuccess { + mmRepository.getPurchaseOrderDetail(purchaseOrderId) + .onSuccess { detail -> + _purchaseOrderDetail.value = detail + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "발주서 상세 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "발주서 상세 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierApp.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierApp.kt index bc8cb0f..8a5e24e 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierApp.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierApp.kt @@ -5,13 +5,18 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.autoever.everp.ui.navigation.CustomNavigationBar @Composable -fun SupplierApp() { +fun SupplierApp( + loginNavController: NavHostController +) { val navController = rememberNavController() Scaffold( @@ -24,17 +29,53 @@ fun SupplierApp() { modifier = Modifier.padding(innerPadding), ) { composable(SupplierNavigationItem.Home.route) { - SupplierHomeScreen() // 공급업체 홈 화면 + SupplierHomeScreen(navController = navController) // 공급업체 홈 화면 } - composable(SupplierNavigationItem.Order.route) { - SupplierOrderScreen() // 주문 화면 + composable(SupplierNavigationItem.PurchaseOrder.route) { + SupplierOrderScreen(navController = navController) // 발주 화면 } - composable(SupplierNavigationItem.Voucher.route) { - SupplierVoucherScreen() // 전표 화면 + composable(SupplierNavigationItem.Invoice.route) { + SupplierVoucherScreen(navController = navController) // 전표 화면 } composable(SupplierNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - SupplierProfileScreen() // 공급업체 프로필 화면 + SupplierProfileScreen( + loginNavController = loginNavController, + navController = navController + ) // 공급업체 프로필 화면 + } + // 서브 네비게이션 아이템들 + composable( + route = SupplierSubNavigationItem.PurchaseOrderDetailItem.route, + ) { backStackEntry -> + val purchaseOrderId = backStackEntry.arguments + ?.getString(SupplierSubNavigationItem.PurchaseOrderDetailItem.ARG_ID) + ?: return@composable + PurchaseOrderDetailScreen(navController = navController) + } + composable( + route = SupplierSubNavigationItem.InvoiceDetailItem.route, + arguments = listOf( + navArgument(SupplierSubNavigationItem.InvoiceDetailItem.ARG_ID) { + type = NavType.StringType + }, + navArgument(SupplierSubNavigationItem.InvoiceDetailItem.ARG_IS_AP) { + type = NavType.BoolType + defaultValue = true + }, + ), + ) { backStackEntry -> + InvoiceDetailScreen(navController = navController) + } + composable( + route = SupplierSubNavigationItem.ProfileEditItem.route, + ) { + SupplierProfileEditScreen(navController = navController) + } + composable( + route = SupplierSubNavigationItem.NotificationItem.route, + ) { + NotificationScreen(navController = navController) } } } @@ -46,7 +87,7 @@ fun SupplierApp() { ) @Composable fun SupplierAppPreview() { - SupplierApp() + SupplierApp(rememberNavController()) } @Preview(showBackground = true) diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt index b25bd25..c5e9e9e 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt @@ -1,10 +1,182 @@ package com.autoever.everp.ui.supplier +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.ui.common.RecentActivityCard +import com.autoever.everp.ui.common.components.QuickActionCard +import com.autoever.everp.ui.common.components.QuickActionIcons +import com.autoever.everp.ui.common.components.StatusBadge +import com.autoever.everp.ui.common.navigateToWorkflowDetail +import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SupplierHomeScreen() { - // 공급업체용 홈 화면 UI 구현 - Text("Supplier Home Screen") +fun SupplierHomeScreen( + navController: NavController, + viewModel: SupplierHomeViewModel = hiltViewModel(), +) { + val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() + val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val hasUnreadNotifications by viewModel.hasUnreadNotifications.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("차량 외장재 관리") }, + actions = { + Box( + modifier = Modifier + .padding(end = 8.dp) + .size(48.dp) + .padding(top = 16.dp, end = 16.dp), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = { + navController.navigate(SupplierSubNavigationItem.NotificationItem.route) + }, + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = "알림", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + // 읽지 않은 알림이 있으면 빨간색 점 표시 + if (hasUnreadNotifications) { + Surface( + modifier = Modifier + .size(8.dp) + .align(Alignment.TopEnd), + shape = CircleShape, + color = Color.Red, + ) { + // 빨간색 점 + } + } + } + }, + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + + item { + Text( + text = "안녕하세요!", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = "오늘도 효율적인 업무 관리를 시작해보세요.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { + Text( + text = "빠른 작업", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + item { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.height(200.dp), + ) { + item { + QuickActionCard( + icon = QuickActionIcons.PurchaseOrderList, + label = "발주", + onClick = { navController.navigate("supplier_purchase_order") }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.InvoiceList, + label = "전표", + onClick = { navController.navigate("supplier_invoice") }, + ) + } + } + } + + item { + Text( + text = "최근 활동", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (isLoading) { + item { + Text(text = "로딩 중...") + } + } else { + recentActivities.forEach { activity -> + item { + val category = categoryMap[activity.id] ?: DashboardTapEnum.UNKNOWN + RecentActivityCard( + category = category.toKorean(), + status = activity.status, + title = activity.description, + date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { + if (activity.tabCode.isSupplierRelated()) { + navigateToWorkflowDetail(navController, category, activity.id) + } + }, + ) + } + } + } + } + } } diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt new file mode 100644 index 0000000..5273e90 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -0,0 +1,100 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.dashboard.DashboardTapEnum +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +import com.autoever.everp.domain.model.notification.NotificationStatusEnum +import com.autoever.everp.domain.repository.AlarmRepository +import com.autoever.everp.domain.repository.DashboardRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierHomeViewModel @Inject constructor( + private val dashboardRepository: DashboardRepository, + private val userRepository: UserRepository, + private val alarmRepository: AlarmRepository, +) : ViewModel() { + + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> + get() = _recentActivities.asStateFlow() + + private val _categoryMap = MutableStateFlow>(emptyMap()) + val categoryMap: StateFlow> + get() = _categoryMap.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _hasUnreadNotifications = MutableStateFlow(false) + val hasUnreadNotifications: StateFlow = _hasUnreadNotifications.asStateFlow() + + init { + loadRecentActivities() + observeNotificationCount() + refreshNotificationCount() + } + + fun loadRecentActivities() { + viewModelScope.launch { + _isLoading.value = true + try { + userRepository.getUserInfo().onSuccess { userInfo -> + val role = userInfo.userRole + dashboardRepository.refreshWorkflows(role).onSuccess { + dashboardRepository.getWorkflows(role).onSuccess { workflows -> + // tabs를 날짜순으로 정렬 + val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } + .take(5) // 최근 5개만 + + _recentActivities.value = sortedTabs + _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } + }.onFailure { e -> + Timber.e(e, "워크플로우 조회 실패") + } + }.onFailure { e -> + Timber.e(e, "워크플로우 갱신 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 조회 실패") + } + } catch (e: Exception) { + Timber.e(e, "최근 활동 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadRecentActivities() + refreshNotificationCount() + } + + private fun observeNotificationCount() { + alarmRepository.observeNotificationCount() + .onEach { count -> + _hasUnreadNotifications.value = count.unreadCount >= 1 + } + .launchIn(viewModelScope) + } + + private fun refreshNotificationCount() { + viewModelScope.launch { + alarmRepository.refreshNotificationCount() + .onFailure { e -> + Timber.e(e, "알림 개수 갱신 실패") + } + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierNavigationItem.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierNavigationItem.kt index 86b60b2..799d689 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierNavigationItem.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierNavigationItem.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Receipt import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.ui.graphics.vector.ImageVector +import com.autoever.everp.ui.customer.CustomerSubNavigationItem import com.autoever.everp.ui.navigation.NavigationItem sealed class SupplierNavigationItem( @@ -18,15 +19,46 @@ sealed class SupplierNavigationItem( override val outlinedIcon: ImageVector, override val filledIcon: ImageVector, ) : NavigationItem { - object Home : SupplierNavigationItem("vendor_home", "홈", Icons.Outlined.Home, Icons.Filled.Home) + object Home : SupplierNavigationItem("supplier_home", "홈", Icons.Outlined.Home, Icons.Filled.Home) - object Order : SupplierNavigationItem("vendor_order", "주문", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart) + object PurchaseOrder : + SupplierNavigationItem("supplier_purchase_order", "발주", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart) - object Voucher : SupplierNavigationItem("vendor_voucher", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) + object Invoice : SupplierNavigationItem("supplier_invoice", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) - object Profile : SupplierNavigationItem("vendor_profile", "프로필", Icons.Outlined.Person, Icons.Filled.Person) + object Profile : SupplierNavigationItem("supplier_profile", "프로필", Icons.Outlined.Person, Icons.Filled.Person) companion object { - val allDestinations = listOf(Home, Order, Voucher, Profile) + val allDestinations = listOf(Home, PurchaseOrder, Invoice, Profile) } } + +sealed class SupplierSubNavigationItem( + val route: String, + val label: String, +) { + object PurchaseOrderDetailItem : + SupplierSubNavigationItem("supplier_purchase_order_detail/{purchaseOrderId}", "발주 상세") { + + const val ARG_ID = "purchaseOrderId" + + fun createRoute(purchaseOrderId: String): String { + return "supplier_purchase_order_detail/$purchaseOrderId" + } + } + + object InvoiceDetailItem : + SupplierSubNavigationItem("supplier_invoice_detail/{invoiceId}?isAp={isAp}", "전표 상세") { + + const val ARG_ID = "invoiceId" + const val ARG_IS_AP = "isAp" + + fun createRoute(invoiceId: String, isAp: Boolean = true): String { + return "supplier_invoice_detail/$invoiceId?isAp=$isAp" + } + } + + object ProfileEditItem : SupplierSubNavigationItem("supplier_profile_edit", "프로필 수정") + + object NotificationItem : SupplierSubNavigationItem("supplier_notification", "알림 목록") +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderScreen.kt index 302362b..a91cc7f 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderScreen.kt @@ -1,10 +1,135 @@ package com.autoever.everp.ui.supplier +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.domain.model.purchase.PurchaseOrderSearchTypeEnum +import com.autoever.everp.ui.common.components.ListCard +import com.autoever.everp.ui.common.components.SearchBar +import com.autoever.everp.ui.common.components.StatusBadge +import java.text.NumberFormat +import java.util.Locale @Composable -fun SupplierOrderScreen() { - // 공급업체용 주문 화면 UI 구현 - Text("Supplier Order Screen") +fun SupplierOrderScreen( + navController: NavController, + viewModel: SupplierOrderViewModel = hiltViewModel(), +) { + val orderList by viewModel.orderList.collectAsState() + val searchParams by viewModel.searchParams.collectAsState() + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 헤더 + Text( + text = "발주 관리", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + // 검색 바 + SearchBar( + query = searchParams.keyword, + onQueryChange = { viewModel.updateSearchQuery(it, PurchaseOrderSearchTypeEnum.PurchaseOrderNumber) }, + placeholder = "발주번호로 검색", + onSearch = { viewModel.search() }, + ) + + // 리스트 + when (uiState) { + is com.autoever.everp.utils.state.UiResult.Loading -> { + Text( + text = "로딩 중...", + modifier = Modifier.padding(16.dp), + ) + } + + is com.autoever.everp.utils.state.UiResult.Error -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + Text( + text = "오류가 발생했습니다: ${(uiState as com.autoever.everp.utils.state.UiResult.Error).exception.message}", + modifier = Modifier.padding(bottom = 8.dp), + ) + Button(onClick = { viewModel.retry() }) { + Text("다시 시도") + } + } + } + + is com.autoever.everp.utils.state.UiResult.Success -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(orderList) { order -> + ListCard( + id = order.number, + title = "${order.supplierName} - ${order.itemsSummary}", + statusBadge = { + StatusBadge( + text = order.status.displayName(), + color = order.status.toColor(), + ) + }, + details = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "납기일: ${order.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "발주금액: ${formatCurrency(order.totalAmount)}원", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + } + }, + trailingContent = { + Button( + onClick = { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(order.id) + ) + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("상세보기") + } + }, + onClick = { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(order.id) + ) + }, + ) + } + } + } + } + } +} + +private fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) } diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderViewModel.kt new file mode 100644 index 0000000..6306cb6 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderViewModel.kt @@ -0,0 +1,125 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.purchase.PurchaseOrderListItem +import com.autoever.everp.domain.model.purchase.PurchaseOrderListParams +import com.autoever.everp.domain.model.purchase.PurchaseOrderSearchTypeEnum +import com.autoever.everp.domain.repository.MmRepository +import com.autoever.everp.utils.state.UiResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierOrderViewModel @Inject constructor( + private val mmRepository: MmRepository, +) : ViewModel() { + + // 로딩/에러 상태만 관리 + private val _uiState = MutableStateFlow>(UiResult.Loading) + val uiState: StateFlow> + get() = _uiState.asStateFlow() + + // 실제 리스트는 별도로 누적 관리 + private val _orderList = MutableStateFlow>(emptyList()) + val orderList: StateFlow> + get() = _orderList.asStateFlow() + + private val _totalPages = MutableStateFlow(0) + val totalPages: StateFlow + get() = _totalPages.asStateFlow() + + private val _hasMore = MutableStateFlow(true) + val hasMore: StateFlow + get() = _hasMore.asStateFlow() + + private val _searchParams = MutableStateFlow( + PurchaseOrderListParams( + statusCode = com.autoever.everp.domain.model.purchase.PurchaseOrderStatusEnum.UNKNOWN, + type = PurchaseOrderSearchTypeEnum.UNKNOWN, + keyword = "", + startDate = null, + endDate = null, + page = 0, + size = 20, + ), + ) + val searchParams: StateFlow + get() = _searchParams.asStateFlow() + + init { + loadOrders() + } + + fun loadOrders(append: Boolean = false) { + viewModelScope.launch { + _uiState.value = UiResult.Loading + + mmRepository.refreshPurchaseOrderList(searchParams.value) + .onSuccess { + // refresh 후 get을 통해 최신 데이터 가져오기 + mmRepository.getPurchaseOrderList(searchParams.value) + .onSuccess { pageResponse -> + if (append) { + // 페이지네이션: 기존 리스트에 추가 + _orderList.value = _orderList.value + pageResponse.content + } else { + // 새로운 검색: 리스트 교체 + _orderList.value = pageResponse.content + } + _totalPages.value = pageResponse.page.totalPages + _hasMore.value = !pageResponse.page.hasNext + _uiState.value = UiResult.Success(Unit) + } + .onFailure { e -> + Timber.e(e, "발주 목록 조회 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + .onFailure { e -> + Timber.e(e, "발주 목록 로드 실패") + _uiState.value = UiResult.Error(e as Exception) + } + } + } + + fun loadNextPage() { + if (_uiState.value is UiResult.Loading || !_hasMore.value) return + + _searchParams.value = _searchParams.value.copy( + page = _searchParams.value.page + 1, + ) + loadOrders(append = true) + } + + fun updateSearchQuery( + query: String, + queryType: PurchaseOrderSearchTypeEnum = PurchaseOrderSearchTypeEnum.UNKNOWN, + ) { + _searchParams.value = _searchParams.value.copy( + keyword = query, + type = queryType, + page = 0, // 검색 시 페이지 초기화 + ) + } + + fun search() { + loadOrders(append = false) // 새로운 검색 + } + + fun retry() { + loadOrders(append = false) + } + + fun refresh() { + _searchParams.value = _searchParams.value.copy(page = 0) + _orderList.value = emptyList() + loadOrders(append = false) + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt new file mode 100644 index 0000000..4e5f8bb --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt @@ -0,0 +1,220 @@ +package com.autoever.everp.ui.supplier + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SupplierProfileEditScreen( + navController: NavController, + viewModel: SupplierProfileEditViewModel = hiltViewModel(), +) { + val profile by viewModel.profile.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() + val isSaving by viewModel.isSaving.collectAsState() + + var companyName by remember { mutableStateOf("") } + var businessNumber by remember { mutableStateOf("") } + var baseAddress by remember { mutableStateOf("") } + var detailAddress by remember { mutableStateOf("") } + var officePhone by remember { mutableStateOf("") } + var userPhoneNumber by remember { mutableStateOf("") } + + LaunchedEffect(profile) { + profile?.let { p -> + companyName = p.companyName + businessNumber = p.businessNumber + baseAddress = p.baseAddress + detailAddress = p.detailAddress + officePhone = p.officePhone + userPhoneNumber = p.userPhoneNumber + } + } + + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text("프로필 편집") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, contentDescription = "뒤로가기") + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 사업자 정보 섹션 + Text( + text = "사업자 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + OutlinedTextField( + value = companyName, + onValueChange = { companyName = it }, + label = { Text("회사명 *") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = businessNumber, + onValueChange = { businessNumber = it }, + label = { Text("사업자등록번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = baseAddress, + onValueChange = { baseAddress = it }, + label = { Text("기본 주소") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = detailAddress, + onValueChange = { detailAddress = it }, + label = { Text("상세 주소") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = officePhone, + onValueChange = { officePhone = it }, + label = { Text("회사 전화번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 개인 정보 섹션 + Text( + text = "개인 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + OutlinedTextField( + value = userInfo?.userName ?: "", + onValueChange = { /* 이름은 수정 불가 */ }, + label = { Text("이름 *") }, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = profile?.userEmail ?: userInfo?.email ?: "", + onValueChange = { /* 이메일은 수정 불가 */ }, + label = { Text("이메일 *") }, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = userPhoneNumber, + onValueChange = { userPhoneNumber = it }, + label = { Text("휴대폰 번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 저장 버튼 + Button( + onClick = { + viewModel.saveProfile( + companyName = companyName, + businessNumber = businessNumber, + baseAddress = baseAddress, + detailAddress = detailAddress, + officePhone = officePhone, + userPhoneNumber = userPhoneNumber, + onSuccess = { + navController.popBackStack() + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isSaving, + ) { + if (isSaving) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.padding(end = 8.dp), + ) + } + Text(if (isSaving) "저장 중..." else "저장") + } + } + } +} diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt new file mode 100644 index 0000000..2242d40 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt @@ -0,0 +1,85 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.ProfileRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierProfileEditViewModel @Inject constructor( + private val userRepository: UserRepository, + private val profileRepository: ProfileRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _profile = MutableStateFlow(null) + val profile: StateFlow = _profile.asStateFlow() + + private val _isSaving = MutableStateFlow(false) + val isSaving: StateFlow = _isSaving.asStateFlow() + + init { + // Flow에서 profile 업데이트 구독 + profileRepository.observeProfile() + .onEach { profile -> + _profile.value = profile + } + .launchIn(viewModelScope) + + loadData() + } + + private fun loadData() { + viewModelScope.launch { + userRepository.getUserInfo().onSuccess { user -> + _userInfo.value = user + // 프로필 정보 로드 + profileRepository.refreshProfile(user.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") + } + } + } + + fun saveProfile( + companyName: String, + businessNumber: String, + baseAddress: String, + detailAddress: String, + officePhone: String, + userPhoneNumber: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + _isSaving.value = true + try { + // TODO: ProfileRepository에 updateProfile 메서드가 추가되면 구현 + // 현재는 Profile 정보만 표시하고, 업데이트 기능은 나중에 추가 예정 + Timber.w("프로필 업데이트 기능은 아직 구현되지 않았습니다.") + // 임시로 성공 처리 + onSuccess() + } catch (e: Exception) { + Timber.e(e, "프로필 저장 실패") + } finally { + _isSaving.value = false + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt index c108b02..82925a5 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt @@ -1,10 +1,202 @@ package com.autoever.everp.ui.supplier +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.size +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.Person +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.home.HomeViewModel @Composable -fun SupplierProfileScreen() { - // 공급업체용 프로필 화면 UI 구현 - Text("Supplier Profile Screen") +fun SupplierProfileScreen( + loginNavController: NavController, + navController: NavController, + viewModel: SupplierProfileViewModel = hiltViewModel(), +) { + val userInfo by viewModel.userInfo.collectAsState() + val profile by viewModel.profile.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // 헤더 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "프로필", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + androidx.compose.material3.TextButton( + onClick = { + navController.navigate(SupplierSubNavigationItem.ProfileEditItem.route) + }, + ) { + Text("편집") + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 사용자 프로필 아이콘 + Icon( + imageVector = Icons.Default.Person, + contentDescription = "프로필", + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .align(Alignment.CenterHorizontally), + tint = MaterialTheme.colorScheme.primary, + ) + + // 사용자 이름과 직책 + Text( + text = userInfo?.userName ?: "로딩 중...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp), + ) + + Text( + text = "${userInfo?.userType?.name ?: ""}·${userInfo?.userRole?.name ?: ""}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // 사업자 정보 섹션 + Text( + text = "사업자 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + ProfileField( + label = "회사명 *", + value = profile?.companyName ?: "", + ) + ProfileField( + label = "사업자등록번호", + value = profile?.businessNumber ?: "", + ) + ProfileField( + label = "주소", + value = profile?.fullAddress ?: "", + ) + ProfileField( + label = "회사 전화번호", + value = profile?.officePhone ?: "", + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 개인 정보 섹션 + Text( + text = "개인 정보", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + ProfileField( + label = "이름 *", + value = profile?.userName ?: userInfo?.userName ?: "", + ) + ProfileField( + label = "이메일 *", + value = profile?.userEmail ?: userInfo?.email ?: "", + ) + ProfileField( + label = "휴대폰 번호", + value = profile?.userPhoneNumber ?: "", + ) + } + } + + Button( + onClick = { + viewModel.logout { + loginNavController.navigate("login") { + popUpTo(0) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + ) { } + } +} + +@Composable +private fun ProfileField( + label: String, + value: String, +) { + OutlinedTextField( + value = value, + onValueChange = { /* 편집 모드에서만 */ }, + label = { Text(label) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) } diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt new file mode 100644 index 0000000..bf757e1 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt @@ -0,0 +1,87 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.auth.session.SessionManager +import com.autoever.everp.domain.model.profile.Profile +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.ProfileRepository +import com.autoever.everp.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierProfileViewModel @Inject constructor( + private val userRepository: UserRepository, + private val profileRepository: ProfileRepository, + private val sessionManager: SessionManager, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _profile = MutableStateFlow(null) + val profile: StateFlow = _profile.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + // Flow에서 profile 업데이트 구독 + profileRepository.observeProfile() + .onEach { profile -> + _profile.value = profile + } + .launchIn(viewModelScope) + + loadUserInfo() + } + + fun loadUserInfo() { + viewModelScope.launch { + _isLoading.value = true + try { + // 사용자 정보 로드 + userRepository.getUserInfo().onSuccess { userInfo -> + _userInfo.value = userInfo + // 프로필 정보 로드 + profileRepository.refreshProfile(userInfo.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") + } + } catch (e: Exception) { + Timber.e(e, "정보 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadUserInfo() + } + + fun logout(onSuccess: () -> Unit) { + viewModelScope.launch { + sessionManager.signOut() + try { + userRepository.logout() + onSuccess() + Timber.i("로그아웃 성공") + } catch (e: Exception) { + Timber.e(e, "로그아웃 실패") + } + } + } +} + diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherScreen.kt index 6f21406..b95a673 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherScreen.kt @@ -1,10 +1,159 @@ package com.autoever.everp.ui.supplier +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Checkbox import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.autoever.everp.ui.common.components.ListCard +import com.autoever.everp.ui.common.components.SearchBar +import com.autoever.everp.ui.common.components.StatusBadge +import java.text.NumberFormat +import java.util.Locale @Composable -fun SupplierVoucherScreen() { - // 공급업체용 바우처 화면 UI 구현 - Text("Supplier Screen") +fun SupplierVoucherScreen( + navController: NavController, + viewModel: SupplierVoucherViewModel = hiltViewModel(), +) { + val invoiceList by viewModel.invoiceList.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() +// val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Column( + modifier = Modifier.fillMaxSize(), + ) { + // 헤더 + Text( + text = "매출전표 관리", + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(16.dp), + ) + + // 검색 바 + SearchBar( + query = searchQuery, + onQueryChange = { viewModel.updateSearchQuery(it) }, + placeholder = "전표번호, 내용, 거래처, 참조번호로 검색", + onSearch = { viewModel.search() }, + ) + +// // 전체 선택 체크박스 +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp, vertical = 8.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// Checkbox( +// checked = selectedInvoiceIds.size == invoiceList.content.size && invoiceList.content.isNotEmpty(), +// onCheckedChange = { +// if (it) { +// viewModel.selectAll() +// } else { +// viewModel.clearSelection() +// } +// }, +// ) +// Text( +// text = "전체 선택", +// style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, +// modifier = Modifier.padding(start = 8.dp), +// ) +// } + + // 리스트 + if (isLoading) { + Text( + text = "로딩 중...", + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(invoiceList.content) { invoice -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { +// Checkbox( +// checked = selectedInvoiceIds.contains(invoice.id), +// onCheckedChange = { viewModel.toggleInvoiceSelection(invoice.id) }, +// modifier = Modifier.padding(start = 8.dp), +// ) + ListCard( + id = invoice.number, + title = invoice.connection.name, + statusBadge = { + StatusBadge( + text = invoice.status.displayName(), + color = invoice.status.toColor(), + ) + }, + details = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "내용: ${invoice.connection.name}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "거래처: ${invoice.connection.name}", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + Text( + text = "금액: ${formatCurrency(invoice.totalAmount)}원", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + ) + Text( + text = "전표 발생일: ${invoice.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + Text( + text = "만기일: ${invoice.dueDate}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + Text( + text = "참조번호: ${invoice.reference.number}", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + }, + onClick = { + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = invoice.id, + isAp = true, + ), + ) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +private fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) } diff --git a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt new file mode 100644 index 0000000..692dc26 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt @@ -0,0 +1,96 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.data.datasource.remote.dto.common.PageResponse +import com.autoever.everp.domain.model.invoice.InvoiceListItem +import com.autoever.everp.domain.model.invoice.InvoiceListParams +import com.autoever.everp.domain.repository.FcmRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierVoucherViewModel @Inject constructor( + private val fcmRepository: FcmRepository, +) : ViewModel() { + + private val _invoiceList = MutableStateFlow>( + PageResponse.empty(), + ) + val invoiceList: StateFlow> = _invoiceList.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + +// private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) +// val selectedInvoiceIds: StateFlow> = _selectedInvoiceIds.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadInvoices() + } + + fun loadInvoices() { + viewModelScope.launch { + _isLoading.value = true + try { + // 공급업체는 매출전표(AR)를 조회 + fcmRepository.refreshArInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { + fcmRepository.getArInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { pageResponse -> + _invoiceList.value = pageResponse + }.onFailure { e -> + Timber.e(e, "매출전표 목록 조회 실패") + } + }.onFailure { e -> + Timber.e(e, "매출전표 목록 갱신 실패") + } + } catch (e: Exception) { + Timber.e(e, "매출전표 목록 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } + + fun search() { + loadInvoices() + } + +// fun toggleInvoiceSelection(invoiceId: String) { +// _selectedInvoiceIds.value = if (_selectedInvoiceIds.value.contains(invoiceId)) { +// _selectedInvoiceIds.value - invoiceId +// } else { +// _selectedInvoiceIds.value + invoiceId +// } +// } +// +// fun selectAll() { +// _selectedInvoiceIds.value = _invoiceList.value.content.map { it.id }.toSet() +// } +// +// fun clearSelection() { +// _selectedInvoiceIds.value = emptySet() +// } +} + diff --git a/secrets.defaults.properties b/secrets.defaults.properties index f7c98a5..8118498 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -1,2 +1,3 @@ BASE_URL=http://localhost:8080/api/ +AUTH_BASE_URL=http://10.0.2.2:8081/ API_KEY=REPLACE_ME_WITH_REAL_KEY