From b30cf04d76cc5a23eaf9a7c783ec637e311c1c34 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:25:17 +0900 Subject: [PATCH 01/70] =?UTF-8?q?remove():=20.gitkeep=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/data/datasource/local/datastore/.gitkeep | 0 .../java/com/autoever/everp/data/datasource/local/pref/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/datastore/.gitkeep delete mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/pref/.gitkeep 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/pref/.gitkeep b/app/src/main/java/com/autoever/everp/data/datasource/local/pref/.gitkeep deleted file mode 100644 index e69de29..0000000 From 6ebd110e7a408b852113009f79361a592b82a7f0 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:37:03 +0900 Subject: [PATCH 02/70] =?UTF-8?q?feat(prop):=20AUTH=20BASE=5FURL=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- secrets.defaults.properties | 1 + 1 file changed, 1 insertion(+) 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 From 106f8e3d4438a89ab157a98d3067564a54be4394 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:38:02 +0900 Subject: [PATCH 03/70] =?UTF-8?q?feat(auth):=20AuthApi=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exchangeAuthCodeForToken - logout - Authorization URL 생성 --- .../datasource/remote/http/service/AuthApi.kt | 79 +++++++++++++++++++ .../remote/interceptor/AuthInterceptor.kt | 2 + 2 files changed, 81 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/AuthApi.kt 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/interceptor/AuthInterceptor.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/interceptor/AuthInterceptor.kt index 2fe2eae..4ec456b 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 @@ -21,11 +21,13 @@ class AuthInterceptor @Inject constructor( val token = getAccessToken() val newRequest = if (token != null) { + // 토큰이 존재하면 Authorization 헤더 추가 -> Authenticated 상태 처리 originalRequest.newBuilder() .header("Authorization", "Bearer $token") .header("Content-Type", "application/json") .build() } else { + // 토큰이 없으면 원본 요청 유지 -> Authenticated 상태 처리 필요 originalRequest.newBuilder() .header("Content-Type", "application/json") .build() From f8de6a7ed2a0b6792b83769704bdb45d3e63a9c0 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:43:09 +0900 Subject: [PATCH 04/70] =?UTF-8?q?feat(auth):=20AuthApi=EC=9A=A9=20Retrofit?= =?UTF-8?q?=20=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 아직 미완성 Auth를 위한 정확한 스펙이 필요하다 --- .../common/annotation/RetrofitAnnotation.kt | 11 ++++ .../com/autoever/everp/di/NetworkModule.kt | 50 ++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/common/annotation/RetrofitAnnotation.kt 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/di/NetworkModule.kt b/app/src/main/java/com/autoever/everp/di/NetworkModule.kt index 7f026b1..17f13e7 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,46 @@ 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) } From 72e7b93d04238195d2cb5f840e1ab000383181b8 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:49:22 +0900 Subject: [PATCH 05/70] =?UTF-8?q?feat(data):=20DataStore=EC=97=90=20Acces&?= =?UTF-8?q?FCM=20=ED=86=A0=ED=81=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenLocalDataSource -> TokenStoreLocalDataSourceImp - TokenPrefLocalDataSourceimpl은 확장성을 위해 추가함 - DataSourceModule에서 연결 --- .../datasource/local/TokenLocalDataSource.kt | 20 +++++ .../TokenDataStoreLocalDataSourceImpl.kt | 74 +++++++++++++++++++ .../pref/TokenPrefLocalDataSourceImpl.kt | 45 +++++++++++ .../com/autoever/everp/di/DataSourceModule.kt | 9 +++ 4 files changed, 148 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/TokenLocalDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/datastore/TokenDataStoreLocalDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/pref/TokenPrefLocalDataSourceImpl.kt 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/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/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/di/DataSourceModule.kt b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt index 7c5b7e4..1fc10f1 100644 --- a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt +++ b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt @@ -4,7 +4,9 @@ import com.autoever.everp.data.datasource.local.AlarmLocalDataSource 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.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 @@ -94,4 +96,11 @@ abstract class DataSourceModule { abstract fun bindsSdLocalDataSource( sdLocalDataSourceImpl: SdLocalDataSourceImpl, ): SdLocalDataSource + + // Token Data Sources (AccessToken, FcmToken) + @Binds + @Singleton + abstract fun bindsTokenLocalDataSource( + tokenDataStoreLocalDataSourceImpl: TokenDataStoreLocalDataSourceImpl + ): TokenLocalDataSource } From 9d7263b05f7e2cc9b9761838cae732657b5442cf Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Wed, 5 Nov 2025 22:50:58 +0900 Subject: [PATCH 06/70] =?UTF-8?q?refac(log):=20=EB=A1=9C=EA=B9=85=ED=88=B4?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log -> Timber --- .../everp/auth/session/SessionManager.kt | 2 +- .../com/autoever/everp/ui/auth/AuthViewModel.kt | 17 +++++++++-------- .../com/autoever/everp/ui/home/HomeViewModel.kt | 17 ++++++++--------- .../autoever/everp/ui/navigation/AppNavGraph.kt | 15 +++++++-------- .../everp/ui/profile/ProfileViewModel.kt | 3 ++- .../ui/redirect/RedirectReceiverActivity.kt | 9 +++++---- 6 files changed, 32 insertions(+), 31 deletions(-) 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..3d67ef2 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 @@ -30,7 +30,7 @@ class SessionManager @Inject constructor( } } catch (e: Exception) { _state.value = AuthState.Unauthenticated - Log.e(TAG, "[ERROR] 저장소에서 토큰을 불러오는 중 오류가 발생했습니다: ${e.message}") + Timber.tag(TAG).e("[ERROR] 저장소에서 토큰을 불러오는 중 오류가 발생했습니다: ${e.message}") } } 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/home/HomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt index d5dade0..c47a592 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,18 +1,18 @@ 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 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( @@ -31,8 +31,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"}, " + @@ -41,10 +40,10 @@ class HomeViewModel @Inject constructor( "userType=${info.userType ?: "null"}" ) } 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 { 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/profile/ProfileViewModel.kt b/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt index 0aee17d..649b98e 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, @@ -46,7 +47,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) } } 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() From f50a5e1dfdd959501defaec99e1132ac4f6b3904 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 00:07:35 +0900 Subject: [PATCH 07/70] =?UTF-8?q?feat(data):=20Auth=20Remote&Local=20DataS?= =?UTF-8?q?ource=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthLocalDataSource : DataStore에 AccessToken값 저장 - AuthRemoteDataSource : HTTP로 연결 --- .../datasource/local/AuthLocalDataSource.kt | 12 ++++ .../AuthDataStoreLocalDataSourceImpl.kt | 71 +++++++++++++++++++ .../datasource/remote/AuthRemoteDataSource.kt | 16 +++++ .../http/impl/AuthHttpRemoteDataSourceImpl.kt | 41 +++++++++++ .../com/autoever/everp/di/DataSourceModule.kt | 15 ++++ 5 files changed, 155 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/AuthLocalDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/AuthRemoteDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AuthHttpRemoteDataSourceImpl.kt 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/datastore/AuthDataStoreLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt new file mode 100644 index 0000000..cd379f0 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt @@ -0,0 +1,71 @@ +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.AuthLocalDataSource +import com.autoever.everp.domain.model.auth.AccessToken +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 AuthDataStoreLocalDataSourceImpl @Inject constructor( + @ApplicationContext private val appContext: Context, +) : 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 + get() = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_ACCESS_TOKEN] } + + override suspend fun getAccessToken(): String? = accessTokenFlow.first() + + suspend fun getAccessTokenWithType(): String? { + val token = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_ACCESS_TOKEN] } + .first() + val type = appContext.dataStore.data + .catch { emit(emptyPreferences()) } + .map { prefs -> prefs[KEY_ACCESS_TOKEN_TYPE] } + .first() + 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) + } + } + +} 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/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..5d2783f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/AuthHttpRemoteDataSourceImpl.kt @@ -0,0 +1,41 @@ +package com.autoever.everp.data.datasource.remote.http.impl + +import com.autoever.everp.auth.api.AuthApi +import com.autoever.everp.data.datasource.remote.AuthRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.TokenResponseDto +import timber.log.Timber +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 { + return try { + val dto = authApi.exchangeAuthCodeForToken( + clientId = clientId, + redirectUri = redirectUri, + code = code, + codeVerifier = codeVerifier, + ) + Result.success(dto) + } catch (e: Exception) { + Timber.tag("Auth").e(e, "exchangeAuthCodeForToken failed") + Result.failure(e) + } + } + + override suspend fun logout(accessTokenWithBearer: String): Result { + return 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/di/DataSourceModule.kt b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt index 1fc10f1..bee66e4 100644 --- a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt +++ b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt @@ -1,6 +1,7 @@ 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 @@ -13,6 +14,7 @@ import com.autoever.everp.data.datasource.local.impl.MmLocalDataSourceImpl 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 @@ -97,6 +99,19 @@ abstract class DataSourceModule { sdLocalDataSourceImpl: SdLocalDataSourceImpl, ): SdLocalDataSource + // Auth Data Sources + @Binds + @Singleton + abstract fun bindsAuthRemoteDataSource( + authHttpRemoteDataSourceImpl: AlarmHttpRemoteDataSourceImpl + ): AuthRemoteDataSource + + @Binds + @Singleton + abstract fun bindsAuthLocalDataSource( + authDataStoreLocalDataSourceImpl: AlarmHttpRemoteDataSourceImpl + ): AuthLocalDataSource + // Token Data Sources (AccessToken, FcmToken) @Binds @Singleton From 08d9890cfa67a918b4fa9690de5fcd3279801df4 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 00:08:13 +0900 Subject: [PATCH 08/70] =?UTF-8?q?feat(domain):=20AccessToken=20Model=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/domain/model/auth/AccessToken.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/domain/model/auth/AccessToken.kt 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" +) From 4614b4a3fa5b059ae48cca6305f88034db0c9698 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 00:43:29 +0900 Subject: [PATCH 09/70] =?UTF-8?q?feat(domain):=20AuthRepository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http/impl/AuthHttpRemoteDataSourceImpl.kt | 14 ++++-- .../data/repository/AuthRepositoryImpl.kt | 48 +++++++++++++++++++ .../com/autoever/everp/di/DataSourceModule.kt | 6 ++- .../com/autoever/everp/di/RepositoryModule.kt | 8 ++++ .../everp/domain/repository/AuthRepository.kt | 23 +++++++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/data/repository/AuthRepositoryImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/repository/AuthRepository.kt 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 index 5d2783f..f1e7eaa 100644 --- 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 @@ -1,9 +1,11 @@ package com.autoever.everp.data.datasource.remote.http.impl -import com.autoever.everp.auth.api.AuthApi import com.autoever.everp.data.datasource.remote.AuthRemoteDataSource +import com.autoever.everp.data.datasource.remote.http.service.AuthApi import com.autoever.everp.data.datasource.remote.http.service.TokenResponseDto +import com.autoever.everp.domain.model.auth.AccessToken import timber.log.Timber +import java.time.LocalDateTime import javax.inject.Inject class AuthHttpRemoteDataSourceImpl @Inject constructor( @@ -14,7 +16,7 @@ class AuthHttpRemoteDataSourceImpl @Inject constructor( redirectUri: String, code: String, codeVerifier: String, - ): Result { + ): Result { return try { val dto = authApi.exchangeAuthCodeForToken( clientId = clientId, @@ -22,7 +24,13 @@ class AuthHttpRemoteDataSourceImpl @Inject constructor( code = code, codeVerifier = codeVerifier, ) - Result.success(dto) + 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) 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/di/DataSourceModule.kt b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt index bee66e4..cfd42e2 100644 --- a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt +++ b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt @@ -7,6 +7,7 @@ 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 @@ -20,6 +21,7 @@ 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.http.impl.SdHttpRemoteDataSourceImpl @@ -103,13 +105,13 @@ abstract class DataSourceModule { @Binds @Singleton abstract fun bindsAuthRemoteDataSource( - authHttpRemoteDataSourceImpl: AlarmHttpRemoteDataSourceImpl + authHttpRemoteDataSourceImpl: AuthHttpRemoteDataSourceImpl ): AuthRemoteDataSource @Binds @Singleton abstract fun bindsAuthLocalDataSource( - authDataStoreLocalDataSourceImpl: AlarmHttpRemoteDataSourceImpl + authDataStoreLocalDataSourceImpl: AuthDataStoreLocalDataSourceImpl ): AuthLocalDataSource // Token Data Sources (AccessToken, FcmToken) 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..69270a4 100644 --- a/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt +++ b/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ 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 @@ -64,4 +66,10 @@ abstract class RepositoryModule { abstract fun bindsUserRepository( userRepositoryImpl: UserRepositoryImpl, ): UserRepository + + @Binds + @Singleton + abstract fun bindsAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository } 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 +} From a97bcd6785b560b2e0b73ba33a75c9e9e27fb648 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 01:41:06 +0900 Subject: [PATCH 10/70] =?UTF-8?q?feat(login):=20DataSource=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedPreference -> DataStore --- .../everp/auth/session/SessionManager.kt | 70 ++++++++++++------- .../annotation/CoroutineScopeAnnotation.kt | 9 +++ .../java/com/autoever/everp/di/AppModule.kt | 11 +++ 3 files changed, 65 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/common/annotation/CoroutineScopeAnnotation.kt 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 3d67ef2..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 - Timber.tag(TAG).e("[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/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) + } } From 03bba070c29f1c237443d2ac35060530b2393949 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 01:42:51 +0900 Subject: [PATCH 11/70] =?UTF-8?q?feat(interceptor):=20Http=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20Header=20=EC=9E=90=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccessToken이 있는 경우 HTTP Header에 자동으로 추가 --- .../remote/interceptor/AuthInterceptor.kt | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) 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 4ec456b..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,33 +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) { - // 토큰이 존재하면 Authorization 헤더 추가 -> Authenticated 상태 처리 + val newRequest = if (token != null && token.isNotBlank()) { + // 토큰이 존재하면 Authorization 헤더 추가 originalRequest.newBuilder() .header("Authorization", "Bearer $token") .header("Content-Type", "application/json") .build() } else { - // 토큰이 없으면 원본 요청 유지 -> Authenticated 상태 처리 필요 + // 토큰이 없으면 원본 요청 유지 (Content-Type만 추가) originalRequest.newBuilder() .header("Content-Type", "application/json") .build() @@ -36,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" } } From 0e2f0eae1310e6b2fccab5eb5b8c4a3fdfcad96d Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 02:52:42 +0900 Subject: [PATCH 12/70] =?UTF-8?q?feat(ui):=20NavHostController=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/autoever/everp/ui/MainScreen.kt | 7 +- .../autoever/everp/ui/customer/CustomerApp.kt | 7 +- .../com/autoever/everp/ui/main/MainScreen.kt | 107 +++++++++++------- .../autoever/everp/ui/supplier/SupplierApp.kt | 7 +- 4 files changed, 82 insertions(+), 46 deletions(-) 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/customer/CustomerApp.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerApp.kt index cf4dba0..7045d8e 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 @@ -6,13 +6,16 @@ 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.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.autoever.everp.ui.navigation.CustomNavigationBar @Composable -fun CustomerApp() { +fun CustomerApp( + loginNavController: NavHostController +) { val navController = rememberNavController() Scaffold( @@ -50,7 +53,7 @@ fun CustomerApp() { ) @Composable fun CustomerAppPreview() { - CustomerApp() + CustomerApp(rememberNavController()) } @Preview(showBackground = true) 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/supplier/SupplierApp.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierApp.kt index bc8cb0f..6519b5f 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,16 @@ 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.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.autoever.everp.ui.navigation.CustomNavigationBar @Composable -fun SupplierApp() { +fun SupplierApp( + loginNavController: NavHostController +) { val navController = rememberNavController() Scaffold( @@ -46,7 +49,7 @@ fun SupplierApp() { ) @Composable fun SupplierAppPreview() { - SupplierApp() + SupplierApp(rememberNavController()) } @Preview(showBackground = true) From 99ea7624ffc57f5fbd5bd3beb3448769605c1f80 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 03:01:54 +0900 Subject: [PATCH 13/70] =?UTF-8?q?feat(ui):=20CustomBottomBar=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=20=ED=83=AD=EC=9D=84=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 동일한 탭을 클릭한 경우 기존 모든 스택을 날림 --- .../everp/ui/navigation/CustomBottomBar.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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..c1b2c52 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,20 @@ 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: 다른 탭을 누름 + // 상태를 저장/복원하며 탭 이동 + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true // 현재 탭의 백 스택 저장 + } + launchSingleTop = true // 중복 화면 방지 + restoreState = true // 이동할 탭의 백 스택 복원 } - launchSingleTop = true - restoreState = true } }, // (5) ⭐ 아이콘 동적 변경 From ba52a26c59f5a36c8fc6565154e9d60eab063723 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 08:46:49 +0900 Subject: [PATCH 14/70] =?UTF-8?q?fix(ui):=20BottomBar=EA=B0=80=20=EC=95=88?= =?UTF-8?q?=EB=88=8C=EB=A6=AC=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomeTab에서 빠른 동작으로 다른 탭으로 이동시 다시 Home으로 이동하기 위해서 탭을 눌렀을 때 이동되지 않는 오류 수정 --- .../everp/ui/navigation/CustomBottomBar.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 c1b2c52..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 @@ -47,13 +47,20 @@ fun CustomNavigationBar( navController.popBackStack(screen.route, inclusive = false) } else { // (B) Behavior 1: 다른 탭을 누름 - // 상태를 저장/복원하며 탭 이동 - navController.navigate(screen.route) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true // 현재 탭의 백 스택 저장 + // 먼저 백 스택에 해당 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 // 이동할 탭의 백 스택 복원 } } }, From 745443edf349c85a710f33f31deca136704420ed Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 08:49:44 +0900 Subject: [PATCH 15/70] =?UTF-8?q?feat(domain):=20Paging=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Domain=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote/dto/common/PageResponse.kt | 15 ++++++- .../everp/domain/model/common/PagingData.kt | 31 +++++++++++++ .../domain/model/common/PagingDataMapper.kt | 43 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/autoever/everp/domain/model/common/PagingData.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/model/common/PagingDataMapper.kt 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..4097aa4 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 @@ -12,7 +12,20 @@ data class PageResponse( 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/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() + From 29edff808a14649239863c4757c34036fa290890 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 09:29:11 +0900 Subject: [PATCH 16/70] =?UTF-8?q?refac(data):=20HTTP=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=98=20Thread=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainThread -> IOThread 로 변경 --- .../impl/AlarmHttpRemoteDataSourceImpl.kt | 29 ++++++++------- .../http/impl/AuthHttpRemoteDataSourceImpl.kt | 13 ++++--- .../http/impl/FcmHttpRemoteDataSourceImpl.kt | 36 +++++++++++-------- .../http/impl/MmHttpRemoteDataSourceImpl.kt | 18 +++++----- .../http/impl/SdHttpRemoteDataSourceImpl.kt | 26 +++++++------- .../http/impl/UserHttpRemoteDataSourceImpl.kt | 8 +++-- 6 files changed, 77 insertions(+), 53 deletions(-) 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..cfc2067 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, 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 index f1e7eaa..17c13ff 100644 --- 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 @@ -2,8 +2,9 @@ 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.data.datasource.remote.http.service.TokenResponseDto 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 @@ -16,8 +17,8 @@ class AuthHttpRemoteDataSourceImpl @Inject constructor( redirectUri: String, code: String, codeVerifier: String, - ): Result { - return try { + ): Result = withContext(Dispatchers.IO) { + try { val dto = authApi.exchangeAuthCodeForToken( clientId = clientId, redirectUri = redirectUri, @@ -37,8 +38,10 @@ class AuthHttpRemoteDataSourceImpl @Inject constructor( } } - override suspend fun logout(accessTokenWithBearer: String): Result { - return try { + 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) { 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..cbe2374 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) 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..a944fa5 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 @@ -9,6 +9,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 +25,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 +42,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 +65,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 +89,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) 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..d521f0a 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 @@ -13,6 +13,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 +36,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 +61,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 +77,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 +94,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) @@ -115,8 +117,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 +141,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) From f420717862764abb9f966b5d6a6057f537c8cc4d Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 09:47:58 +0900 Subject: [PATCH 17/70] =?UTF-8?q?feat(dto):=20PageResponse=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EA=B0=92=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/autoever/everp/MainActivity.kt | 32 +++---------------- .../local/impl/AlarmLocalDataSourceImpl.kt | 5 +-- .../local/impl/FcmLocalDataSourceImpl.kt | 14 +++----- .../local/impl/MmLocalDataSourceImpl.kt | 10 ++---- .../local/impl/SdLocalDataSourceImpl.kt | 14 +++----- .../remote/dto/common/PageResponse.kt | 7 ++-- .../data/repository/AlarmRepositoryImpl.kt | 1 + .../data/repository/FcmRepositoryImpl.kt | 2 ++ .../everp/data/repository/MmRepositoryImpl.kt | 1 + .../everp/data/repository/SdRepositoryImpl.kt | 2 ++ 10 files changed, 27 insertions(+), 61 deletions(-) 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/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/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/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/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/remote/dto/common/PageResponse.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/PageResponse.kt index 4097aa4..675f392 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,10 +8,12 @@ import kotlinx.serialization.Serializable */ @Serializable data class PageResponse( - @SerialName("items") + @SerialName("content") val content: List, - @SerialName("page") + @SerialName("pageInfo") val page: PageDto, + @SerialName("total") + val total: Int, ) { companion object { fun empty(): PageResponse = PageResponse( @@ -23,6 +25,7 @@ data class PageResponse( totalPages = 0, hasNext = false, ), + total = 0, ) } } 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..f729acd 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 @@ -46,6 +46,7 @@ class AlarmRepositoryImpl @Inject constructor( PageResponse( content = NotificationMapper.toDomainList(dtoPage.content), page = dtoPage.page, // PageDto 그대로 전달 + total = dtoPage.total ) } } 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..d9165f5 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 @@ -44,6 +44,7 @@ class FcmRepositoryImpl @Inject constructor( PageResponse( content = InvoiceMapper.toDomainList(dtoPage.content), page = dtoPage.page, + total = dtoPage.total, ) } } @@ -100,6 +101,7 @@ class FcmRepositoryImpl @Inject constructor( PageResponse( content = InvoiceMapper.toDomainList(dtoPage.content), page = dtoPage.page, + total = dtoPage.total, ) } } 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..acb7616 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 @@ -76,6 +76,7 @@ class MmRepositoryImpl @Inject constructor( PageResponse( content = PurchaseOrderMapper.toDomainList(dtoPage.content), page = dtoPage.page, + total = dtoPage.total, ) } } 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..85e6f2a 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 @@ -53,6 +53,7 @@ class SdRepositoryImpl @Inject constructor( SdMapper.quotationListItemToDomain(it) }, // 이미 QuotationListItem page = dtoPage.page, + total = dtoPage.total, ) } } @@ -116,6 +117,7 @@ class SdRepositoryImpl @Inject constructor( PageResponse( content = SdMapper.salesOrderListToDomainList(dtoPage.content), page = dtoPage.page, + total = dtoPage.total, ) } } From ac3905edce70d18f13908ef34e709d977069504c Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 09:56:04 +0900 Subject: [PATCH 18/70] =?UTF-8?q?refac(data):=20DataStore=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=98=20Thread=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainThread -> IOThread --- .../AuthDataStoreLocalDataSourceImpl.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) 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 index cd379f0..529a9e5 100644 --- 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 @@ -10,9 +10,12 @@ import androidx.datastore.preferences.preferencesDataStore 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.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -29,22 +32,24 @@ class AuthDataStoreLocalDataSourceImpl @Inject constructor( 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 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? { - val token = appContext.dataStore.data - .catch { emit(emptyPreferences()) } - .map { prefs -> prefs[KEY_ACCESS_TOKEN] } - .first() - val type = appContext.dataStore.data + // 1. dataStore에서 Preferences를 한 번만 읽어옵니다. + val prefs = appContext.dataStore.data .catch { emit(emptyPreferences()) } - .map { prefs -> prefs[KEY_ACCESS_TOKEN_TYPE] } .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 { From 09ba15185174e24c9a9180182747f6d15c365b75 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 10:15:11 +0900 Subject: [PATCH 19/70] =?UTF-8?q?feat(dto):=20PageResponse=20DTO=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필드 제거 --- .../everp/data/datasource/remote/dto/common/PageResponse.kt | 5 +---- .../autoever/everp/data/repository/AlarmRepositoryImpl.kt | 1 - .../com/autoever/everp/data/repository/FcmRepositoryImpl.kt | 2 -- .../com/autoever/everp/data/repository/MmRepositoryImpl.kt | 1 - .../com/autoever/everp/data/repository/SdRepositoryImpl.kt | 2 -- 5 files changed, 1 insertion(+), 10 deletions(-) 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 675f392..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 @@ -10,10 +10,8 @@ import kotlinx.serialization.Serializable data class PageResponse( @SerialName("content") val content: List, - @SerialName("pageInfo") + @SerialName("page") val page: PageDto, - @SerialName("total") - val total: Int, ) { companion object { fun empty(): PageResponse = PageResponse( @@ -25,7 +23,6 @@ data class PageResponse( totalPages = 0, hasNext = false, ), - total = 0, ) } } 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 f729acd..1836e2e 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 @@ -46,7 +46,6 @@ class AlarmRepositoryImpl @Inject constructor( PageResponse( content = NotificationMapper.toDomainList(dtoPage.content), page = dtoPage.page, // PageDto 그대로 전달 - total = dtoPage.total ) } } 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 d9165f5..9d2bc9a 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 @@ -44,7 +44,6 @@ class FcmRepositoryImpl @Inject constructor( PageResponse( content = InvoiceMapper.toDomainList(dtoPage.content), page = dtoPage.page, - total = dtoPage.total, ) } } @@ -101,7 +100,6 @@ class FcmRepositoryImpl @Inject constructor( PageResponse( content = InvoiceMapper.toDomainList(dtoPage.content), page = dtoPage.page, - total = dtoPage.total, ) } } 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 acb7616..14934d2 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 @@ -76,7 +76,6 @@ class MmRepositoryImpl @Inject constructor( PageResponse( content = PurchaseOrderMapper.toDomainList(dtoPage.content), page = dtoPage.page, - total = dtoPage.total, ) } } 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 85e6f2a..517891c 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 @@ -53,7 +53,6 @@ class SdRepositoryImpl @Inject constructor( SdMapper.quotationListItemToDomain(it) }, // 이미 QuotationListItem page = dtoPage.page, - total = dtoPage.total, ) } } @@ -117,7 +116,6 @@ class SdRepositoryImpl @Inject constructor( PageResponse( content = SdMapper.salesOrderListToDomainList(dtoPage.content), page = dtoPage.page, - total = dtoPage.total, ) } } From 3802d1c17583c558a3e1377be0990b419418eebc Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 10:40:13 +0900 Subject: [PATCH 20/70] =?UTF-8?q?refac(data):=20Repository=EC=9D=98=20?= =?UTF-8?q?=EB=AC=B4=EA=B1=B0=EC=9A=B4=20=EC=9E=91=EC=97=85=20Thread=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainThread -> DefaultThread --- .../data/repository/AlarmRepositoryImpl.kt | 16 ++++++++------ .../everp/data/repository/MmRepositoryImpl.kt | 10 +++++---- .../everp/data/repository/SdRepositoryImpl.kt | 22 ++++++++++++------- 3 files changed, 29 insertions(+), 19 deletions(-) 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..fee055c 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,10 @@ 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.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -26,16 +28,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, @@ -54,7 +56,7 @@ class AlarmRepositoryImpl @Inject constructor( alarmLocalDataSource.observeNotificationCount() override suspend fun refreshNotificationCount( - status: NotificationStatusEnum + status: NotificationStatusEnum, ): Result { return getNotificationCount(status).map { count -> alarmLocalDataSource.setNotificationCount(count) @@ -63,8 +65,8 @@ class AlarmRepositoryImpl @Inject constructor( override suspend fun getNotificationCount( status: NotificationStatusEnum - ): Result { - return alarmRemoteDataSource.getNotificationCount(status = status) + ): Result = withContext(Dispatchers.Default) { + alarmRemoteDataSource.getNotificationCount(status = status) .map { NotificationMapper.toDomain(it) } } 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/SdRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/SdRepositoryImpl.kt index 517891c..bd98db4 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, @@ -95,16 +99,18 @@ class SdRepositoryImpl @Inject constructor( 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, From 2d18f2d62e7374e8261770d21c24457858f1ce4e Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 10:48:56 +0900 Subject: [PATCH 21/70] =?UTF-8?q?feat(ui):=20Customer=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/common/components/ListCard.kt | 77 +++++++ .../ui/common/components/QuickActionCard.kt | 80 +++++++ .../everp/ui/common/components/SearchBar.kt | 61 ++++++ .../everp/ui/common/components/StatusBadge.kt | 37 ++++ .../autoever/everp/ui/customer/CustomerApp.kt | 10 +- .../everp/ui/customer/CustomerHomeScreen.kt | 204 +++++++++++++++++- .../ui/customer/CustomerHomeViewModel.kt | 53 +++++ .../everp/ui/customer/CustomerOrderScreen.kt | 102 ++++++++- .../ui/customer/CustomerOrderViewModel.kt | 76 +++++++ .../ui/customer/CustomerProfileScreen.kt | 180 +++++++++++++++- .../ui/customer/CustomerProfileViewModel.kt | 62 ++++++ .../ui/customer/CustomerQuotationScreen.kt | 187 +++++++++++++++- .../ui/customer/CustomerQuotationViewModel.kt | 122 +++++++++++ .../ui/customer/CustomerVoucherScreen.kt | 148 ++++++++++++- .../ui/customer/CustomerVoucherViewModel.kt | 89 ++++++++ 15 files changed, 1468 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/common/components/ListCard.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/common/components/QuickActionCard.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/common/components/SearchBar.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/common/components/StatusBadge.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt 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 7045d8e..353e2b7 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 @@ -28,20 +28,20 @@ fun CustomerApp( 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() // 주문 화면 + CustomerOrderScreen(navController = navController) // 주문 화면 } composable(CustomerNavigationItem.Voucher.route) { - CustomerVoucherScreen() // 전표 화면 + CustomerVoucherScreen(navController = navController) // 전표 화면 } composable(CustomerNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - CustomerProfileScreen() // 고객사 프로필 화면 + CustomerProfileScreen(navController = navController) // 고객사 프로필 화면 } } } 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..8b20de3 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,208 @@ package com.autoever.everp.ui.customer +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +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.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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +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 java.time.format.DateTimeFormatter @Composable -fun CustomerHomeScreen() { - // 고객용 홈 화면 UI 구현 - Text(text = "Customer Home Screen") +fun CustomerHomeScreen( + navController: NavController, + viewModel: CustomerHomeViewModel = hiltViewModel(), +) { + val recentQuotations by viewModel.recentQuotations.collectAsStateWithLifecycle() + val recentOrders by viewModel.recentOrders.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + Text( + text = "차량 외장재 관리", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + } + + 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 = { /* TODO: 견적 요청 화면으로 이동 */ }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.QuotationList, + label = "견적 목록", + onClick = { navController.navigate("customer_quotation") }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.PurchaseOrderList, + label = "주문 관리", + onClick = { navController.navigate("customer_order") }, + ) + } + item { + QuickActionCard( + icon = QuickActionIcons.InvoiceList, + label = "매입전표", + onClick = { navController.navigate("customer_voucher") }, + ) + } + } + } + + item { + Text( + text = "최근 활동", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (isLoading) { + item { + Text(text = "로딩 중...") + } + } else { + // 견적서 활동 + recentQuotations.forEach { quotation -> + item { + RecentActivityCard( + status = quotation.status.displayName(), + statusColor = quotation.status.toColor(), + title = "${quotation.number} - ${quotation.product.productId}", + date = quotation.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { /* TODO: 견적 상세 화면으로 이동 */ }, + ) + } + } + + // 주문서 활동 + recentOrders.forEach { order -> + item { + RecentActivityCard( + status = order.statusCode.displayName(), + statusColor = order.statusCode.toColor(), + title = "${order.salesOrderNumber} - ${order.customerName}", + date = order.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { /* TODO: 주문 상세 화면으로 이동 */ }, + ) + } + } + } + } +} + +@Composable +private fun RecentActivityCard( + status: String, + statusColor: androidx.compose.ui.graphics.Color, + 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, + ) { + StatusBadge( + text = status, + color = statusColor, + ) + 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, + ) + } + } } 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..b0b0ea9 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -0,0 +1,53 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.repository.SdRepository +import com.autoever.everp.domain.model.quotation.QuotationListItem +import com.autoever.everp.domain.model.quotation.QuotationListParams +import com.autoever.everp.domain.model.sale.SalesOrderListItem +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 CustomerHomeViewModel @Inject constructor( + private val sdRepository: SdRepository, +) : ViewModel() { + + private val _recentQuotations = MutableStateFlow>(emptyList()) + val recentQuotations: StateFlow> + get() = _recentQuotations.asStateFlow() + + private val _recentOrders = MutableStateFlow>(emptyList()) + val recentOrders: StateFlow> = _recentOrders.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadRecentActivities() + } + + fun loadRecentActivities() { + viewModelScope.launch { + _isLoading.value = true + try { + + } catch (e: Exception) { + Timber.e(e, "최근 활동 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadRecentActivities() + } +} + 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..5305e8f 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,106 @@ 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.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 searchQuery by viewModel.searchQuery.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() }, + ) + + // 리스트 + if (isLoading) { + Text( + text = "로딩 중...", + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(orderList.content) { 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 = { /* TODO: 주문 상세 화면으로 이동 */ }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("상세보기") + } + }, + onClick = { /* TODO: 주문 상세 화면으로 이동 */ }, + ) + } + } + } + } +} + +private fun formatCurrency(amount: Long): String { + return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) } 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..44ac7c4 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt @@ -0,0 +1,76 @@ +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.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 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 _orderList = MutableStateFlow>( + PageResponse.empty(), + ) + val orderList: StateFlow> = _orderList.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadOrders() + } + + fun loadOrders() { + viewModelScope.launch { + _isLoading.value = true + try { + sdRepository.refreshSalesOrderList( + SalesOrderListParams( + searchKeyword = _searchQuery.value, + searchType = if (_searchQuery.value.isNotBlank()) { + SalesOrderSearchTypeEnum.SALES_ORDER_NUMBER + } else { + SalesOrderSearchTypeEnum.UNKNOWN + }, + page = 0, + size = 20, + ), + ).onSuccess { + sdRepository.observeSalesOrderList().collect { pageResponse -> + _orderList.value = pageResponse + } + }.onFailure { e -> + Timber.e(e, "주문 목록 로드 실패") + } + } catch (e: Exception) { + Timber.e(e, "주문 목록 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } + + fun search() { + loadOrders() + } +} + 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..f5ee602 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,184 @@ 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.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( + navController: NavController, + viewModel: CustomerProfileViewModel = hiltViewModel(), +) { + val userInfo by viewModel.userInfo.collectAsState() + val customerDetail by viewModel.customerDetail.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 = { /* TODO: 편집 화면으로 이동 */ }, + ) { + 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 = customerDetail?.customerName ?: "", + ) + ProfileField( + label = "회사 주소", + value = customerDetail?.fullAddress ?: "", + ) + ProfileField( + label = "회사 전화번호", + value = customerDetail?.contactPhone ?: "", + ) + ProfileField( + label = "사업자등록번호", + value = customerDetail?.businessNumber ?: "", + ) + } + } + + 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 = userInfo?.userName ?: "", + ) + ProfileField( + label = "이메일 *", + value = userInfo?.email ?: "", + ) + ProfileField( + label = "휴대폰 번호", + value = customerDetail?.managerPhone ?: "", + ) + } + } + } +} + +@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..20b9182 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt @@ -0,0 +1,62 @@ +package com.autoever.everp.ui.customer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.customer.CustomerDetail +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.SdRepository +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.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomerProfileViewModel @Inject constructor( + private val userRepository: UserRepository, + private val sdRepository: SdRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _customerDetail = MutableStateFlow(null) + val customerDetail: StateFlow = _customerDetail.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadUserInfo() + } + + fun loadUserInfo() { + viewModelScope.launch { + _isLoading.value = true + try { + // 사용자 정보 로드 + userRepository.getUserInfo().onSuccess { userInfo -> + _userInfo.value = userInfo + // 고객사 정보 로드 + userInfo.userId.let { customerId -> + sdRepository.getCustomerDetail(customerId).onSuccess { customerDetail -> + _customerDetail.value = customerDetail + } + } + } + } catch (e: Exception) { + Timber.e(e, "사용자 정보 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadUserInfo() + } +} + 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..a6522c7 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,191 @@ 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 = { /* TODO: 견적 요청 화면으로 이동 */ }, + 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 = { /* 상세 화면 */ }, + ) + } + + // 페이지네이션 로딩 + 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..5ccfe9c --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt @@ -0,0 +1,122 @@ +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 { + sdRepository.observeQuotationList().collect { 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) + } + } + } + + 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..92386c4 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,152 @@ 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 = { /* TODO: 전표 상세 화면으로 이동 */ }, + 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/customer/CustomerVoucherViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt new file mode 100644 index 0000000..a40e340 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt @@ -0,0 +1,89 @@ +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> = _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 { + // 고객사는 매입전표(AP)를 조회 + fcmRepository.refreshApInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { + fcmRepository.observeApInvoiceList().collect { pageResponse -> + _invoiceList.value = pageResponse + } + }.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() + } +} + From dcf2ffcbc6a797fa359af4b472d5634c803b39b7 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 12:07:55 +0900 Subject: [PATCH 22/70] =?UTF-8?q?refac(data):=20FcmRepository=EC=9D=98=20?= =?UTF-8?q?=EB=AC=B4=EA=B1=B0=EC=9A=B4=20=EC=9E=91=EC=97=85=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainThread -> DefaultThread --- .../data/repository/FcmRepositoryImpl.kt | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) 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..68631ab 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, From c75e4f17b590c76cf0fd3ac9aa3a98cf14f45e8d Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 14:39:00 +0900 Subject: [PATCH 23/70] =?UTF-8?q?feat(ui):=20Navigation=20Item=20=EB=B0=8F?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/autoever/everp/ui/customer/CustomerApp.kt | 5 ++--- .../everp/ui/customer/CustomerNavigationItem.kt | 6 +++--- .../java/com/autoever/everp/ui/supplier/SupplierApp.kt | 4 ++-- .../everp/ui/supplier/SupplierNavigationItem.kt | 10 +++++----- 4 files changed, 12 insertions(+), 13 deletions(-) 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 353e2b7..ed0d252 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 @@ -3,7 +3,6 @@ package com.autoever.everp.ui.customer 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.NavHostController @@ -33,10 +32,10 @@ fun CustomerApp( composable(CustomerNavigationItem.Quotation.route) { CustomerQuotationScreen(navController = navController) // 견적 화면 } - composable(CustomerNavigationItem.Order.route) { + composable(CustomerNavigationItem.SalesOrder.route) { CustomerOrderScreen(navController = navController) // 주문 화면 } - composable(CustomerNavigationItem.Voucher.route) { + composable(CustomerNavigationItem.Invoice.route) { CustomerVoucherScreen(navController = navController) // 전표 화면 } composable(CustomerNavigationItem.Profile.route) { 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..8fa08a2 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,13 @@ 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) } } 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 6519b5f..139ea41 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 @@ -29,10 +29,10 @@ fun SupplierApp( composable(SupplierNavigationItem.Home.route) { SupplierHomeScreen() // 공급업체 홈 화면 } - composable(SupplierNavigationItem.Order.route) { + composable(SupplierNavigationItem.PurchaseOrder.route) { SupplierOrderScreen() // 주문 화면 } - composable(SupplierNavigationItem.Voucher.route) { + composable(SupplierNavigationItem.Invoice.route) { SupplierVoucherScreen() // 전표 화면 } composable(SupplierNavigationItem.Profile.route) { 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..2722ef3 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 @@ -18,15 +18,15 @@ 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) } } From da5f4346b656740db72c08913510430563ca7f84 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 16:47:30 +0900 Subject: [PATCH 24/70] =?UTF-8?q?fix(data):=20JSON=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/datasource/remote/http/service/MmApi.kt | 12 +++++++----- .../datasource/remote/mapper/PurchaseOrderMapper.kt | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) 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..571b851 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 @@ -6,6 +6,7 @@ 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 +16,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 @@ -155,14 +157,14 @@ data class PurchaseOrderListItemDto( val supplierName: String, @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, ) 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..b0b1e60 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, ) } From fc00762e6a9ccdddccd32426128ee9fd3f383809 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 16:48:35 +0900 Subject: [PATCH 25/70] =?UTF-8?q?feat(ui):=20Supplier=EC=9D=98=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20bottom=20she?= =?UTF-8?q?et=EC=97=90=20=EB=8C=80=ED=95=9C=20navigation=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/autoever/everp/ui/supplier/SupplierApp.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 139ea41..e1afa11 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 @@ -27,17 +27,17 @@ fun SupplierApp( modifier = Modifier.padding(innerPadding), ) { composable(SupplierNavigationItem.Home.route) { - SupplierHomeScreen() // 공급업체 홈 화면 + SupplierHomeScreen(navController = navController) // 공급업체 홈 화면 } composable(SupplierNavigationItem.PurchaseOrder.route) { - SupplierOrderScreen() // 주문 화면 + SupplierOrderScreen(navController = navController) // 발주 화면 } composable(SupplierNavigationItem.Invoice.route) { - SupplierVoucherScreen() // 전표 화면 + SupplierVoucherScreen(navController = navController) // 전표 화면 } composable(SupplierNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - SupplierProfileScreen() // 공급업체 프로필 화면 + SupplierProfileScreen(navController = navController) // 공급업체 프로필 화면 } } } From f6e8fed9b01c80d25a6a0de528dec9b4068d54ce Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Thu, 6 Nov 2025 16:49:19 +0900 Subject: [PATCH 26/70] =?UTF-8?q?feat(ui):=20=EA=B3=B5=EA=B8=89=EC=82=AC?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/supplier/SupplierHomeScreen.kt | 190 +++++++++++++++++- .../ui/supplier/SupplierHomeViewModel.kt | 98 +++++++++ .../everp/ui/supplier/SupplierOrderScreen.kt | 123 +++++++++++- .../ui/supplier/SupplierOrderViewModel.kt | 125 ++++++++++++ .../ui/supplier/SupplierProfileScreen.kt | 180 ++++++++++++++++- .../ui/supplier/SupplierProfileViewModel.kt | 62 ++++++ .../ui/supplier/SupplierVoucherScreen.kt | 148 +++++++++++++- .../ui/supplier/SupplierVoucherViewModel.kt | 96 +++++++++ 8 files changed, 1010 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt 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..855bdb9 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,194 @@ 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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +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.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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +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 java.time.format.DateTimeFormatter @Composable -fun SupplierHomeScreen() { - // 공급업체용 홈 화면 UI 구현 - Text("Supplier Home Screen") +fun SupplierHomeScreen( + navController: NavController, + viewModel: SupplierHomeViewModel = hiltViewModel(), +) { + val recentPurchaseOrders by viewModel.recentPurchaseOrders.collectAsStateWithLifecycle() + val recentInvoices by viewModel.recentInvoices.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + Text( + text = "차량 외장재 관리", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + } + + 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 { + // 발주서 활동 + recentPurchaseOrders.forEach { purchaseOrder -> + item { + RecentActivityCard( + status = purchaseOrder.status.displayName(), + statusColor = purchaseOrder.status.toColor(), + title = "${purchaseOrder.number} - ${purchaseOrder.itemsSummary}", + date = purchaseOrder.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { /* TODO: 발주 상세 화면으로 이동 */ }, + ) + } + } + + // 전표 활동 + recentInvoices.forEach { invoice -> + item { + RecentActivityCard( + status = invoice.status.displayName(), + statusColor = invoice.status.toColor(), + title = "${invoice.number} - ${invoice.connection.name}", + date = invoice.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { /* TODO: 전표 상세 화면으로 이동 */ }, + ) + } + } + } + } +} + +@Composable +private fun RecentActivityCard( + status: String, + statusColor: androidx.compose.ui.graphics.Color, + 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, + ) { + StatusBadge( + text = status, + color = statusColor, + ) + 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, + ) + } + } } 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..7589140 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -0,0 +1,98 @@ +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.invoice.InvoiceListItem +import com.autoever.everp.domain.model.invoice.InvoiceListParams +import com.autoever.everp.domain.repository.MmRepository +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 SupplierHomeViewModel @Inject constructor( + private val mmRepository: MmRepository, + private val fcmRepository: FcmRepository, +) : ViewModel() { + + private val _recentPurchaseOrders = MutableStateFlow>(emptyList()) + val recentPurchaseOrders: StateFlow> + get() = _recentPurchaseOrders.asStateFlow() + + private val _recentInvoices = MutableStateFlow>(emptyList()) + val recentInvoices: StateFlow> + get() = _recentInvoices.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow + get() = _isLoading.asStateFlow() + + init { + loadRecentActivities() + } + + fun loadRecentActivities() { + viewModelScope.launch { + _isLoading.value = true + try { + // 최근 발주서 3개 로드 + mmRepository.refreshPurchaseOrderList( + PurchaseOrderListParams( + page = 0, + size = 3, + ), + ).onSuccess { + mmRepository.getPurchaseOrderList( + PurchaseOrderListParams( + page = 0, + size = 3, + ), + ).onSuccess { pageResponse -> + _recentPurchaseOrders.value = pageResponse.content + }.onFailure { e -> + Timber.e(e, "최근 발주서 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "최근 발주서 갱신 실패") + } + + // 최근 전표 3개 로드 (공급업체는 AR 인보이스 조회) + fcmRepository.refreshArInvoiceList( + InvoiceListParams( + page = 0, + size = 3, + ), + ).onSuccess { + fcmRepository.getArInvoiceList( + InvoiceListParams( + page = 0, + size = 3, + ), + ).onSuccess { pageResponse -> + _recentInvoices.value = pageResponse.content + }.onFailure { e -> + Timber.e(e, "최근 전표 로드 실패") + } + }.onFailure { e -> + Timber.e(e, "최근 전표 갱신 실패") + } + } catch (e: Exception) { + Timber.e(e, "최근 활동 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadRecentActivities() + } +} + 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..c395647 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,127 @@ 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 = { /* TODO: 발주 상세 화면으로 이동 */ }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("상세보기") + } + }, + onClick = { /* TODO: 발주 상세 화면으로 이동 */ }, + ) + } + } + } + } + } +} + +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/SupplierProfileScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileScreen.kt index c108b02..c1d3e10 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,184 @@ 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.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 SupplierProfileScreen() { - // 공급업체용 프로필 화면 UI 구현 - Text("Supplier Profile Screen") +fun SupplierProfileScreen( + navController: NavController, + viewModel: SupplierProfileViewModel = hiltViewModel(), +) { + val userInfo by viewModel.userInfo.collectAsState() + val supplierDetail by viewModel.supplierDetail.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 = { /* TODO: 편집 화면으로 이동 */ }, + ) { + 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 = supplierDetail?.name ?: "", + ) + ProfileField( + label = "회사 주소", + value = supplierDetail?.fullAddress ?: "", + ) + ProfileField( + label = "회사 전화번호", + value = supplierDetail?.phone ?: "", + ) + ProfileField( + label = "회사 이메일", + value = supplierDetail?.email ?: "", + ) + } + } + + 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 = userInfo?.userName ?: "", + ) + ProfileField( + label = "이메일 *", + value = userInfo?.email ?: "", + ) + ProfileField( + label = "휴대폰 번호", + value = supplierDetail?.manager?.phone ?: "", + ) + } + } + } +} + +@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..d80902e --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt @@ -0,0 +1,62 @@ +package com.autoever.everp.ui.supplier + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.autoever.everp.domain.model.supplier.SupplierDetail +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.MmRepository +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.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierProfileViewModel @Inject constructor( + private val userRepository: UserRepository, + private val mmRepository: MmRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _supplierDetail = MutableStateFlow(null) + val supplierDetail: StateFlow = _supplierDetail.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadUserInfo() + } + + fun loadUserInfo() { + viewModelScope.launch { + _isLoading.value = true + try { + // 사용자 정보 로드 + userRepository.getUserInfo().onSuccess { userInfo -> + _userInfo.value = userInfo + // 공급업체 정보 로드 + userInfo.userId.let { supplierId -> + mmRepository.getSupplierDetail(supplierId).onSuccess { supplierDetail -> + _supplierDetail.value = supplierDetail + } + } + } + } catch (e: Exception) { + Timber.e(e, "사용자 정보 로드 실패") + } finally { + _isLoading.value = false + } + } + } + + fun refresh() { + loadUserInfo() + } +} + 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..b585a5f 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,152 @@ 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 = { /* TODO: 전표 상세 화면으로 이동 */ }, + 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..6286d3f --- /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() + } +} + From 39169e5dda7d77313385cc9d00a0e908a259cdca Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Fri, 7 Nov 2025 23:57:53 +0900 Subject: [PATCH 27/70] =?UTF-8?q?feat(util):=20=ED=95=A8=EC=88=98=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/ui/customer/CustomerVoucherScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 92386c4..85679a8 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 @@ -147,6 +147,6 @@ fun CustomerVoucherScreen( } } -private fun formatCurrency(amount: Long): String { +fun formatCurrency(amount: Long): String { return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) } From 7b8803256d9a64445622fc5911be79bb66f75faa Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sat, 8 Nov 2025 23:42:26 +0900 Subject: [PATCH 28/70] =?UTF-8?q?feat(api):=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile, MM, IM, Dashboard --- .../remote/http/service/DashboardApi.kt | 66 +++++++++++++++++++ .../datasource/remote/http/service/ImApi.kt | 31 ++++++++- .../datasource/remote/http/service/MmApi.kt | 19 +++++- .../remote/http/service/ProfileApi.kt | 30 +++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt 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..0807aa1 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt @@ -0,0 +1,66 @@ +package com.autoever.everp.data.datasource.remote.http.service + + +import com.autoever.everp.domain.model.user.UserRoleEnum +import com.autoever.everp.utils.serializer.LocalDateSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import retrofit2.http.GET +import retrofit2.http.Query +import java.time.LocalDate + +interface DashboardApi { + + /** + * 대시보드 워크플로우 조회 + */ + @GET("$BASE_URL/workflows") + suspend fun getDashboardWorkflows( + @Query("role") role: UserRoleEnum, + ): DashboardWorkflowsResponseDto + + companion object { + private const val BASE_URL = "dashboard" + } +} + +@Serializable +data class DashboardWorkflowsResponseDto( + @SerialName("role") + val role: String, + @SerialName("tabs") + val tabs: List, +) { + @Serializable + data class DashboardWorkflowTabDto( + @SerialName("tabCode") + val tabCode: String, + @SerialName("items") + val items: List, + ) { + @Serializable + data class DashboardWorkflowTabItemDto( + @SerialName("itemId") + val workflowId: String, + @SerialName("itemNumber") + val count: Int, + @SerialName("itemTitle") + val workflowName: String, + @SerialName("name") + val name: String, + @SerialName("statusCode") + val statusCode: String, + @SerialName("data") + @Serializable(with = LocalDateSerializer::class) + val data: LocalDate, + ) + } +} + + +/* + +@GET("$BASE_URL/statistics") + suspend fun getDashboardStatistics(): DashboardStatisticsResponseDto + + */ 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..6f12237 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,44 @@ 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 ItemToggleResponseDto( + @SerialName("itemId") + val itemId: String, + @SerialName("itemNumber") + val itemNumber: String, + @SerialName("itemName") + val itemName: String, + @SerialName("uomName") + val uomName: String, + @SerialName("unitPrice") + val unitPrice: Long, +// @SerialName("supplierCompanyId") +// val supplierCompanyId: String, +// @SerialName("supplierCompanyName") +// val supplierCompanyName: String, +) + /* // ========== 재고 아이템 관리 ========== @GET("$BASE_URL/inventory-items") @@ -134,9 +162,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 571b851..ea252ec 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,6 +2,7 @@ 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 @@ -67,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" } @@ -154,7 +171,7 @@ 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 = LocalDateTimeSerializer::class) 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..332dcd4 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt @@ -0,0 +1,30 @@ +package com.autoever.everp.data.datasource.remote.http.service + +import com.autoever.everp.data.datasource.remote.dto.common.ApiResponse +import retrofit2.http.GET + +interface ProfileApi { + + @GET(BASE_URL) + suspend fun getProfile( + + ): ApiResponse + + companion object { + private const val BASE_URL = "business/profile" + } +} + +@kotlinx.serialization.Serializable +data class ProfileResponseDto( + @kotlinx.serialization.SerialName("businessName") + val businessName: String, + @kotlinx.serialization.SerialName("businessNumber") + val businessNumber: String, + @kotlinx.serialization.SerialName("ceoName") + val ceoName: String, + @kotlinx.serialization.SerialName("address") + val address: String, + @kotlinx.serialization.SerialName("contactNumber") + val contactNumber: String, +) From adcc0beaa1e75f4f0c9708a6b44cd3753b431d5c Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sat, 8 Nov 2025 23:43:38 +0900 Subject: [PATCH 29/70] =?UTF-8?q?feat(dto):=20ToggleResponseDto=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색, 필터링 등 Toggle을 통해 선택이 필요한 경우 반환값 --- .../remote/dto/common/ToggleResponseDto.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/dto/common/ToggleResponseDto.kt 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, +) From 95bd8f0c80303c8d2e6a7613b7b960a3c11a4855 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 15:59:31 +0900 Subject: [PATCH 30/70] =?UTF-8?q?feat(auth):=20Logout=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile 화면에서 로그아웃 버튼 추가 - 로그아웃 시 DataStore의 데이터 삭제 - 로그아웃 시 Login 화면으로 이동 --- .../autoever/everp/auth/api/HttpUserApi.kt | 7 ++++++- .../auth/repository/DefaultUserRepository.kt | 5 ++++- .../AuthDataStoreLocalDataSourceImpl.kt | 6 +++++- .../remote/http/service/ProfileApi.kt | 14 +++++++------ .../data/repository/UserRepositoryImpl.kt | 3 +++ .../everp/ui/profile/ProfileViewModel.kt | 2 ++ .../ui/supplier/SupplierProfileScreen.kt | 20 ++++++++++++++++++- .../ui/supplier/SupplierProfileViewModel.kt | 15 ++++++++++++++ 8 files changed, 62 insertions(+), 10 deletions(-) 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/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/local/datastore/AuthDataStoreLocalDataSourceImpl.kt index 529a9e5..815c360 100644 --- 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 @@ -7,20 +7,23 @@ 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.flow 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 { @@ -70,6 +73,7 @@ class AuthDataStoreLocalDataSourceImpl @Inject constructor( 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/remote/http/service/ProfileApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt index 332dcd4..830b65e 100644 --- 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 @@ -1,6 +1,8 @@ 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 { @@ -15,16 +17,16 @@ interface ProfileApi { } } -@kotlinx.serialization.Serializable +@Serializable data class ProfileResponseDto( - @kotlinx.serialization.SerialName("businessName") + @SerialName("businessName") val businessName: String, - @kotlinx.serialization.SerialName("businessNumber") + @SerialName("businessNumber") val businessNumber: String, - @kotlinx.serialization.SerialName("ceoName") + @SerialName("ceoName") val ceoName: String, - @kotlinx.serialization.SerialName("address") + @SerialName("address") val address: String, - @kotlinx.serialization.SerialName("contactNumber") + @SerialName("contactNumber") val contactNumber: String, ) 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/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/autoever/everp/ui/profile/ProfileViewModel.kt index 649b98e..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 @@ -26,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)) @@ -63,6 +64,7 @@ class ProfileViewModel @Inject constructor( } } finally { sessionManager.signOut() + user.logout() } } } 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 c1d3e10..903c09b 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 @@ -13,6 +13,7 @@ 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 @@ -29,9 +30,11 @@ 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( + loginNavController: NavController, navController: NavController, viewModel: SupplierProfileViewModel = hiltViewModel(), ) { @@ -57,7 +60,9 @@ fun SupplierProfileScreen( fontWeight = FontWeight.Bold, ) androidx.compose.material3.TextButton( - onClick = { /* TODO: 편집 화면으로 이동 */ }, + onClick = { + navController.navigate(SupplierSubNavigationItem.ProfileEditItem.route) + }, ) { Text("편집") } @@ -164,6 +169,19 @@ fun SupplierProfileScreen( ) } } + + Button( + onClick = { + viewModel.logout { + loginNavController.navigate("login") { + popUpTo(0) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.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 index d80902e..3eb8ce6 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt @@ -2,6 +2,7 @@ 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.supplier.SupplierDetail import com.autoever.everp.domain.model.user.UserInfo import com.autoever.everp.domain.repository.MmRepository @@ -18,6 +19,7 @@ import javax.inject.Inject class SupplierProfileViewModel @Inject constructor( private val userRepository: UserRepository, private val mmRepository: MmRepository, + private val sessionManager: SessionManager, ) : ViewModel() { private val _userInfo = MutableStateFlow(null) @@ -58,5 +60,18 @@ class SupplierProfileViewModel @Inject constructor( fun refresh() { loadUserInfo() } + + fun logout(onSuccess: () -> Unit) { + viewModelScope.launch { + sessionManager.signOut() + try { + userRepository.logout() + onSuccess() + Timber.i("로그아웃 성공") + } catch (e: Exception) { + Timber.e(e, "로그아웃 실패") + } + } + } } From 374d8ab9adf5e83a7148d2e844bd5be9da041966 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:05:15 +0900 Subject: [PATCH 31/70] =?UTF-8?q?feat(profile):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=94=84=EB=A1=9C=ED=95=84=20data-domain=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile Remote&Local DataSource -> ProfileRepository --- .../local/ProfileLocalDataSource.kt | 11 +++++++ .../local/impl/ProfileLocalDataSourceImpl.kt | 25 ++++++++++++++ .../remote/ProfileRemoteDataSource.kt | 8 +++++ .../impl/ProfileHttpRemoteDataSourceImpl.kt | 33 +++++++++++++++++++ .../datasource/remote/mapper/ProfileMapper.kt | 16 +++++++++ .../data/repository/ProfileRepositoryImpl.kt | 31 +++++++++++++++++ .../everp/domain/model/profile/Profile.kt | 10 ++++++ .../domain/repository/ProfileRepository.kt | 11 +++++++ 8 files changed, 145 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/ProfileLocalDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/impl/ProfileLocalDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ProfileHttpRemoteDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt create mode 100644 app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/model/profile/Profile.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt 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/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/remote/ProfileRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt new file mode 100644 index 0000000..aa0ec06 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/ProfileRemoteDataSource.kt @@ -0,0 +1,8 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.domain.model.profile.Profile + +interface ProfileRemoteDataSource { + suspend fun getProfile(): Result +} + 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..b824f73 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ProfileHttpRemoteDataSourceImpl.kt @@ -0,0 +1,33 @@ +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.ProfileApi +import com.autoever.everp.data.datasource.remote.http.service.ProfileResponseDto +import com.autoever.everp.domain.model.profile.Profile +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( + + ): Result = withContext(Dispatchers.IO) { + runCatching { + val response = profileApi.getProfile() //.data ?: throw Exception("Profile data is null") + response.data?.let { dto: ProfileResponseDto -> + Profile( + businessName = dto.businessName, + businessNumber = dto.businessNumber, + ceoName = dto.ceoName, + address = dto.address, + contactNumber = dto.contactNumber, + ) + } ?: throw Exception("Profile data is null") + } + } + +} + 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..b799d3e --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt @@ -0,0 +1,16 @@ +package com.autoever.everp.data.datasource.remote.mapper + +import com.autoever.everp.data.datasource.remote.http.service.ProfileResponseDto +import com.autoever.everp.domain.model.profile.Profile + +object ProfileMapper { + fun toDomain(dto: ProfileResponseDto): Profile = + Profile( + businessName = dto.businessName, + businessNumber = dto.businessNumber, + ceoName = dto.ceoName, + address = dto.address, + contactNumber = dto.contactNumber, + ) +} + 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..83c67fe --- /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.data.datasource.remote.mapper.ProfileMapper +import com.autoever.everp.domain.model.profile.Profile +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(): Result = withContext(Dispatchers.Default) { + getProfile().map { profile -> + profileLocalDataSource.setProfile(profile) + } + } + + override suspend fun getProfile(): Result = withContext(Dispatchers.Default) { + profileRemoteDataSource.getProfile() + } +} + 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..84c39b5 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/profile/Profile.kt @@ -0,0 +1,10 @@ +package com.autoever.everp.domain.model.profile + +data class Profile( + val businessName: String, + val businessNumber: String, + val ceoName: String, + val address: String, + val contactNumber: String, +) + 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..b398793 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt @@ -0,0 +1,11 @@ +package com.autoever.everp.domain.repository + +import com.autoever.everp.domain.model.profile.Profile +import kotlinx.coroutines.flow.Flow + +interface ProfileRepository { + fun observeProfile(): Flow + suspend fun refreshProfile(): Result + suspend fun getProfile(): Result +} + From 808d467d7c0325098de33d47a09778d10794d6b7 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:16:49 +0900 Subject: [PATCH 32/70] =?UTF-8?q?feat(im):=20=EA=B2=AC=EC=A0=81=EC=84=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=A3=BC=EB=AC=B8=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?Item=20=EC=A1=B0=ED=9A=8C=20data-domain=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Im Remote&Local DataSource - Im Repository --- .../datasource/local/ImLocalDataSource.kt | 12 +++++++ .../local/impl/ImLocalDataSourceImpl.kt | 24 ++++++++++++++ .../datasource/remote/ImRemoteDataSource.kt | 8 +++++ .../http/impl/ImHttpRemoteDataSourceImpl.kt | 33 +++++++++++++++++++ .../data/datasource/remote/mapper/ImMapper.kt | 18 ++++++++++ .../everp/data/repository/ImRepositoryImpl.kt | 31 +++++++++++++++++ .../model/inventory/InventoryItemToggle.kt | 15 +++++++++ .../everp/domain/repository/ImRepository.kt | 10 ++++++ 8 files changed, 151 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/ImLocalDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/impl/ImLocalDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ImMapper.kt create mode 100644 app/src/main/java/com/autoever/everp/data/repository/ImRepositoryImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/model/inventory/InventoryItemToggle.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/repository/ImRepository.kt 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/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/remote/ImRemoteDataSource.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt new file mode 100644 index 0000000..1788b20 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/ImRemoteDataSource.kt @@ -0,0 +1,8 @@ +package com.autoever.everp.data.datasource.remote + +import com.autoever.everp.data.datasource.remote.http.service.ItemToggleResponseDto +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/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..dc8d7a1 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt @@ -0,0 +1,33 @@ +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.data.datasource.remote.http.service.ItemToggleResponseDto +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) { + runCatching { + val data = imApi.getItemsToggle().data + + data?.map { dto -> + InventoryItemToggle( + itemId = dto.itemId, + itemName = dto.itemName, + uomName = dto.uomName, + unitPrice = dto.unitPrice, +// supplierCompanyId = dto.supplierCompanyId, +// supplierCompanyName = dto.supplierCompanyName, + ) + } ?: emptyList() + } + } +} 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..6a63bd3 --- /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.ItemToggleResponseDto +import com.autoever.everp.domain.model.inventory.InventoryItemToggle + +object ImMapper { + fun toDomain(dto: ItemToggleResponseDto): InventoryItemToggle = + InventoryItemToggle( + itemId = dto.itemId, + itemName = dto.itemName, + uomName = dto.uomName, + unitPrice = dto.unitPrice, +// supplierCompanyId = dto.supplierCompanyId, +// supplierCompanyName = dto.supplierCompanyName, + ) +} + + 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/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/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> +} From 3da4f909dac53d2db4667aad1a78acd69097fe93 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:19:39 +0900 Subject: [PATCH 33/70] =?UTF-8?q?feat(dashboard):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=B5=9C=EA=B7=BC=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20data-domain=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard Remote&Local DataSource - Dashboard Repository --- .../local/DashboardLocalDataSource.kt | 11 +++++++ .../impl/DashboardLocalDataSourceImpl.kt | 25 ++++++++++++++ .../remote/DashboardRemoteDataSource.kt | 9 +++++ .../impl/DashboardHttpRemoteDataSourceImpl.kt | 18 ++++++++++ .../remote/mapper/DashboardMapper.kt | 27 +++++++++++++++ .../repository/DashboardRepositoryImpl.kt | 33 +++++++++++++++++++ .../everp/domain/model/dashboard/Dashboard.kt | 23 +++++++++++++ .../domain/repository/DashboardRepository.kt | 12 +++++++ 8 files changed, 158 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/DashboardLocalDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/local/impl/DashboardLocalDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/DashboardRemoteDataSource.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/DashboardHttpRemoteDataSourceImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/DashboardMapper.kt create mode 100644 app/src/main/java/com/autoever/everp/data/repository/DashboardRepositoryImpl.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt create mode 100644 app/src/main/java/com/autoever/everp/domain/repository/DashboardRepository.kt 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/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/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/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..bae5333 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/DashboardHttpRemoteDataSourceImpl.kt @@ -0,0 +1,18 @@ +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 javax.inject.Inject + +class DashboardHttpRemoteDataSourceImpl @Inject constructor( + private val dashboardApi: DashboardApi, +) : DashboardRemoteDataSource { + + override suspend fun getDashboardWorkflows(role: UserRoleEnum): Result = + runCatching { + dashboardApi.getDashboardWorkflows(role) + } +} + 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..10e10f4 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/DashboardMapper.kt @@ -0,0 +1,27 @@ +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( + role = dto.role, + tabs = dto.tabs.map { tab -> + DashboardWorkflows.DashboardWorkflowTab( + tabCode = tab.tabCode, + items = tab.items.map { item -> + DashboardWorkflows.DashboardWorkflowItem( + workflowId = item.workflowId, + count = item.count, + workflowName = item.workflowName, + name = item.name, + statusCode = item.statusCode, + date = item.data, + ) + }, + ) + }, + ) +} + 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/domain/model/dashboard/Dashboard.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt new file mode 100644 index 0000000..148139f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt @@ -0,0 +1,23 @@ +package com.autoever.everp.domain.model.dashboard + +import java.time.LocalDate + +data class DashboardWorkflows( + val role: String, + val tabs: List, +) { + data class DashboardWorkflowTab( + val tabCode: String, + val items: List, + ) + + data class DashboardWorkflowItem( + val workflowId: String, + val count: Int, + val workflowName: String, + val name: String, + val statusCode: String, + val date: LocalDate, + ) +} + 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 +} + From 01e4378ed529d70ef9b53c6cff7144a5c793dec4 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:29:28 +0900 Subject: [PATCH 34/70] =?UTF-8?q?feat(mm):=20=EB=B0=9C=EC=A3=BC=EC=84=9C?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20data-domain=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발주서 검색 타입, 상태 타입 토글 조회 --- .../datasource/remote/MmRemoteDataSource.kt | 11 +++++++ .../http/impl/MmHttpRemoteDataSourceImpl.kt | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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/http/impl/MmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/MmHttpRemoteDataSourceImpl.kt index a944fa5..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 @@ -102,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) + } + } } From c7ada5b4d861b25e09713ed9176c092fcd0c005b Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:31:30 +0900 Subject: [PATCH 35/70] =?UTF-8?q?feat(sd):=20=EA=B3=A0=EA=B0=9D=EC=82=AC?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98=EC=A0=95=20data-doam?= =?UTF-8?q?in=20=ED=9D=90=EB=A6=84=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SdRemtoeDataSource - SdRepository --- .../datasource/remote/SdRemoteDataSource.kt | 6 ++++++ .../http/impl/SdHttpRemoteDataSourceImpl.kt | 18 ++++++++++++++++++ .../everp/data/repository/SdRepositoryImpl.kt | 11 +++++++++++ .../everp/domain/repository/SdRepository.kt | 5 +++++ 4 files changed, 40 insertions(+) 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/http/impl/SdHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/SdHttpRemoteDataSourceImpl.kt index d521f0a..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 @@ -108,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?, 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 bd98db4..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 @@ -95,6 +95,17 @@ 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() 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 From b426b65db9225196257fb81ff7f8ce6c6bbf2ce5 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:32:38 +0900 Subject: [PATCH 36/70] =?UTF-8?q?feat(di):=20Repository,=20DataSource,=20R?= =?UTF-8?q?etrofit=20=EA=B0=9D=EC=B2=B4=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/di/DataSourceModule.kt | 43 +++++++++++++++++++ .../com/autoever/everp/di/NetworkModule.kt | 10 +++++ .../com/autoever/everp/di/RepositoryModule.kt | 20 +++++++++ 3 files changed, 73 insertions(+) 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 cfd42e2..4b9866f 100644 --- a/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt +++ b/app/src/main/java/com/autoever/everp/di/DataSourceModule.kt @@ -12,6 +12,8 @@ import com.autoever.everp.data.datasource.local.datastore.TokenDataStoreLocalDat 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 @@ -24,6 +26,8 @@ import com.autoever.everp.data.datasource.remote.http.impl.AlarmHttpRemoteDataSo 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 @@ -101,6 +105,19 @@ abstract class DataSourceModule { 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 @@ -120,4 +137,30 @@ abstract class DataSourceModule { 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 17f13e7..8d04981 100644 --- a/app/src/main/java/com/autoever/everp/di/NetworkModule.kt +++ b/app/src/main/java/com/autoever/everp/di/NetworkModule.kt @@ -160,4 +160,14 @@ object NetworkModule { @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 69270a4..d220ae8 100644 --- a/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt +++ b/app/src/main/java/com/autoever/everp/di/RepositoryModule.kt @@ -5,6 +5,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -61,6 +63,12 @@ abstract class RepositoryModule { mmRepositoryImpl: MmRepositoryImpl, ): MmRepository + @Binds + @Singleton + abstract fun bindsImRepository( + imRepositoryImpl: ImRepositoryImpl, + ): ImRepository + @Binds @Singleton abstract fun bindsUserRepository( @@ -72,4 +80,16 @@ abstract class RepositoryModule { 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 } From bf4951ef35a1bb1ac540e1165c967dc153ae6c7a Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:35:10 +0900 Subject: [PATCH 37/70] =?UTF-8?q?feat(ui):=20=EA=B3=B5=EA=B8=89=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20ui&viewmodel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공급자 전표 리스트, 전표 상세 화면 - 공급사 프로필, 프로필 수정 화면 - 공급사 발주 상세 화면 --- .../everp/ui/supplier/InvoiceDetailScreen.kt | 348 +++++++++++++++ .../ui/supplier/InvoiceDetailViewModel.kt | 85 ++++ .../ui/supplier/PurchaseOrderDetailScreen.kt | 398 ++++++++++++++++++ .../everp/ui/supplier/SupplierOrderScreen.kt | 12 +- .../ui/supplier/SupplierProfileEditScreen.kt | 209 +++++++++ .../supplier/SupplierProfileEditViewModel.kt | 105 +++++ .../ui/supplier/SupplierVoucherScreen.kt | 9 +- 7 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt 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..af99f71 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt @@ -0,0 +1,348 @@ +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.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.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() + + 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), + ) { + // 좌우 2열 레이아웃 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // 왼쪽 열 + Column(modifier = Modifier.weight(1f)) { + DetailRow( + label = "전표번호", + value = detail.number, + ) + DetailRow( + label = "전표유형", + value = detail.type.displayName(), + ) + DetailRow( + label = "거래처", + value = detail.connectionName, + ) + DetailRow( + label = "적요", + value = detail.note.ifBlank { "-" }, + ) + } + + // 오른쪽 열 + Column(modifier = Modifier.weight(1f)) { + 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(1.5f), + ) + 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.unitOfMaterialName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1.5f), + ) + 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 + ) + } + } + } + } + } + } + + 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/InvoiceDetailViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt new file mode 100644 index 0000000..fa48122 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt @@ -0,0 +1,85 @@ +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, + ) ?: "" + + private 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() + + 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 { + 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) + } + } + } + } +} + 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..88fbf86 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt @@ -0,0 +1,398 @@ +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(1.5f), + ) + 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.uomName, // 규격은 uomName으로 대체 + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1.5f), + ) + 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)) + + // 배송 및 메모 정보 카드 + 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 = "본사 창고", // TODO: 실제 데이터에서 가져오기 + ) + DetailRow( + label = "요청 배송일", + value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + ) + DetailRow( + label = "특별 지시사항", + value = "오전 배송 요청", // TODO: 실제 데이터에서 가져오기 + ) + if (detail.note.isNotEmpty()) { + 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/SupplierOrderScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierOrderScreen.kt index c395647..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 @@ -107,13 +107,21 @@ fun SupplierOrderScreen( }, trailingContent = { Button( - onClick = { /* TODO: 발주 상세 화면으로 이동 */ }, + onClick = { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(order.id) + ) + }, modifier = Modifier.padding(top = 8.dp), ) { Text("상세보기") } }, - onClick = { /* TODO: 발주 상세 화면으로 이동 */ }, + onClick = { + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute(order.id) + ) + }, ) } } 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..a80e6ac --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt @@ -0,0 +1,209 @@ +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 supplierDetail by viewModel.supplierDetail.collectAsState() + val userInfo by viewModel.userInfo.collectAsState() + val isSaving by viewModel.isSaving.collectAsState() + + var companyName by remember { mutableStateOf("") } + var companyAddress by remember { mutableStateOf("") } + var companyPhone by remember { mutableStateOf("") } + var companyEmail by remember { mutableStateOf("") } + var managerPhone by remember { mutableStateOf("") } + + LaunchedEffect(supplierDetail) { + supplierDetail?.let { detail -> + companyName = detail.name + companyAddress = detail.fullAddress + companyPhone = detail.phone + companyEmail = detail.email + managerPhone = detail.manager?.phone ?: "" + } + } + + 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 = companyAddress, + onValueChange = { companyAddress = it }, + label = { Text("회사 주소") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = companyPhone, + onValueChange = { companyPhone = it }, + label = { Text("회사 전화번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = companyEmail, + onValueChange = { companyEmail = 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 = userInfo?.email ?: "", + onValueChange = { /* 이메일은 수정 불가 */ }, + label = { Text("이메일 *") }, + enabled = false, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + OutlinedTextField( + value = managerPhone, + onValueChange = { managerPhone = it }, + label = { Text("휴대폰 번호") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // 저장 버튼 + Button( + onClick = { + viewModel.saveProfile( + companyName = companyName, + companyAddress = companyAddress, + companyPhone = companyPhone, + companyEmail = companyEmail, + managerPhone = managerPhone, + 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..fc2c440 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.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.http.service.SupplierUpdateRequestDto +import com.autoever.everp.domain.model.supplier.SupplierDetail +import com.autoever.everp.domain.model.user.UserInfo +import com.autoever.everp.domain.repository.MmRepository +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.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SupplierProfileEditViewModel @Inject constructor( + private val userRepository: UserRepository, + private val mmRepository: MmRepository, +) : ViewModel() { + + private val _userInfo = MutableStateFlow(null) + val userInfo: StateFlow = _userInfo.asStateFlow() + + private val _supplierDetail = MutableStateFlow(null) + val supplierDetail: StateFlow = _supplierDetail.asStateFlow() + + private val _isSaving = MutableStateFlow(false) + val isSaving: StateFlow = _isSaving.asStateFlow() + + init { + loadData() + } + + private fun loadData() { + viewModelScope.launch { + userRepository.getUserInfo().onSuccess { user -> + _userInfo.value = user + user.userId.let { supplierId -> + mmRepository.getSupplierDetail(supplierId).onSuccess { detail -> + _supplierDetail.value = detail + } + } + } + } + } + + fun saveProfile( + companyName: String, + companyAddress: String, + companyPhone: String, + companyEmail: String, + managerPhone: String, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + _isSaving.value = true + try { + val supplierId = _userInfo.value?.userId ?: return@launch + + val supplierDetail = _supplierDetail.value + ?: return@launch + + val (baseAddress, detailAddress) = parseAddress(companyAddress) + val request = SupplierUpdateRequestDto( + supplierName = companyName, + supplierEmail = companyEmail, + supplierPhone = companyPhone, + supplierBaseAddress = baseAddress, + supplierDetailAddress = detailAddress, + category = supplierDetail.category, + statusCode = supplierDetail.status, + deliveryLeadTime = supplierDetail.deliveryLeadTime, + managerName = supplierDetail.manager.name, + managerPhone = managerPhone, + managerEmail = supplierDetail.manager.email, + ) + + mmRepository.updateSupplier(supplierId, request) + .onSuccess { + Timber.i("프로필 저장 성공") + onSuccess() + } + .onFailure { e -> + Timber.e(e, "프로필 저장 실패") + } + } finally { + _isSaving.value = false + } + } + } + + private fun parseAddress(fullAddress: String): Pair { + // 간단한 주소 파싱: 공백으로 구분하여 첫 번째 부분을 baseAddress, 나머지를 detailAddress로 + val parts = fullAddress.trim().split(" ", limit = 2) + return if (parts.size == 2) { + Pair(parts[0], parts[1].takeIf { it.isNotBlank() }) + } else { + Pair(fullAddress, null) + } + } +} + 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 b585a5f..e4be7eb 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 @@ -137,7 +137,14 @@ fun SupplierVoucherScreen( ) } }, - onClick = { /* TODO: 전표 상세 화면으로 이동 */ }, + onClick = { + navController.navigate( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = invoice.id, + isAp = true, + ), + ) + }, modifier = Modifier.weight(1f), ) } From c13a0dc097feea4758c4b1502a60a49c075b3093 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:36:30 +0900 Subject: [PATCH 38/70] =?UTF-8?q?feat(ui):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=9C=84=ED=95=9C=20SupplierSubN?= =?UTF-8?q?avigationItem=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supplier/PurchaseOrderDetailViewModel.kt | 62 +++++++++++++++++++ .../autoever/everp/ui/supplier/SupplierApp.kt | 35 ++++++++++- .../ui/supplier/SupplierNavigationItem.kt | 32 +++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailViewModel.kt 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 e1afa11..fc52773 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 @@ -6,9 +6,11 @@ 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 @@ -37,7 +39,38 @@ fun SupplierApp( } composable(SupplierNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - SupplierProfileScreen(navController = navController) // 공급업체 프로필 화면 + 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) } } } 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 2722ef3..d2b7f5a 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( @@ -20,7 +21,8 @@ sealed class SupplierNavigationItem( ) : NavigationItem { object Home : SupplierNavigationItem("supplier_home", "홈", Icons.Outlined.Home, Icons.Filled.Home) - object PurchaseOrder : SupplierNavigationItem("supplier_purchase_order", "발주", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart) + object PurchaseOrder : + SupplierNavigationItem("supplier_purchase_order", "발주", Icons.Outlined.ShoppingCart, Icons.Filled.ShoppingCart) object Invoice : SupplierNavigationItem("supplier_invoice", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) @@ -30,3 +32,31 @@ sealed class SupplierNavigationItem( 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", "프로필 수정") +} From 9187244609de2d43c1eb48abe52f992d34532510 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:41:37 +0900 Subject: [PATCH 39/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20UI,=20ViewModel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고객사 주문 리스트, 주문 상세 화면 - 고객사 전표 리스트, 전표 상세, 전표 생성 화면 - 고객사 전표 리스트, 전표 상세 화면 - 고객사 프로필, 프로필 수정 화면 - 고객사 주문 리스트, 주문 상세 화면 --- .../everp/ui/customer/CustomerOrderScreen.kt | 129 +++--- .../ui/customer/CustomerOrderViewModel.kt | 121 ++++-- .../ui/customer/CustomerProfileEditScreen.kt | 11 + .../ui/customer/CustomerProfileScreen.kt | 15 + .../ui/customer/CustomerProfileViewModel.kt | 15 + .../ui/customer/CustomerQuotationScreen.kt | 8 +- .../ui/customer/CustomerQuotationViewModel.kt | 28 +- .../ui/customer/CustomerVoucherScreen.kt | 8 +- .../ui/customer/CustomerVoucherViewModel.kt | 21 +- .../everp/ui/customer/InvoiceDetailScreen.kt | 331 ++++++++++++++ .../ui/customer/InvoiceDetailViewModel.kt | 67 +++ .../ui/customer/QuotationCreateScreen.kt | 344 +++++++++++++++ .../ui/customer/QuotationCreateViewModel.kt | 144 ++++++ .../ui/customer/QuotationDetailScreen.kt | 369 ++++++++++++++++ .../ui/customer/QuotationDetailViewModel.kt | 50 +++ .../ui/customer/SalesOrderDetailScreen.kt | 410 ++++++++++++++++++ .../ui/customer/SalesOrderDetailViewModel.kt | 50 +++ 17 files changed, 2016 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailViewModel.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/SalesOrderDetailViewModel.kt 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 5305e8f..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 @@ -16,6 +16,7 @@ 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 @@ -28,8 +29,8 @@ fun CustomerOrderScreen( viewModel: CustomerOrderViewModel = hiltViewModel(), ) { val orderList by viewModel.orderList.collectAsState() - val searchQuery by viewModel.searchQuery.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() + val searchParams by viewModel.searchParams.collectAsState() + val uiState by viewModel.uiState.collectAsState() Column( modifier = Modifier.fillMaxSize(), @@ -44,63 +45,91 @@ fun CustomerOrderScreen( // 검색 바 SearchBar( - query = searchQuery, - onQueryChange = { viewModel.updateSearchQuery(it) }, + query = searchParams.searchKeyword, + onQueryChange = { viewModel.updateSearchQuery(it, SalesOrderSearchTypeEnum.SALES_ORDER_NUMBER) }, placeholder = "주문번호로 검색", onSearch = { viewModel.search() }, ) // 리스트 - if (isLoading) { - Text( - text = "로딩 중...", - modifier = Modifier.padding(16.dp), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - ) { - items(orderList.content) { 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, + 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(), ) - Text( - text = "주문금액: ${formatCurrency(order.totalAmount)}원", - style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - color = androidx.compose.material3.MaterialTheme.colorScheme.primary, + }, + 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 + ) ) - } - }, - trailingContent = { - Button( - onClick = { /* TODO: 주문 상세 화면으로 이동 */ }, - modifier = Modifier.padding(top = 8.dp), - ) { - Text("상세보기") - } - }, - onClick = { /* TODO: 주문 상세 화면으로 이동 */ }, - ) + }, + ) + } } } } } } - -private fun formatCurrency(amount: Long): String { - return NumberFormat.getNumberInstance(Locale.KOREA).format(amount) -} 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 index 44ac7c4..afb24eb 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerOrderViewModel.kt @@ -2,11 +2,11 @@ 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.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 @@ -20,57 +20,106 @@ class CustomerOrderViewModel @Inject constructor( private val sdRepository: SdRepository, ) : ViewModel() { - private val _orderList = MutableStateFlow>( - PageResponse.empty(), - ) - val orderList: StateFlow> = _orderList.asStateFlow() + // 로딩/에러 상태만 관리 + 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 _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() + private val _totalPages = MutableStateFlow(0) + val totalPages: StateFlow + get() = _totalPages.asStateFlow() - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.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() { + fun loadOrders(append: Boolean = false) { viewModelScope.launch { - _isLoading.value = true - try { - sdRepository.refreshSalesOrderList( - SalesOrderListParams( - searchKeyword = _searchQuery.value, - searchType = if (_searchQuery.value.isNotBlank()) { - SalesOrderSearchTypeEnum.SALES_ORDER_NUMBER - } else { - SalesOrderSearchTypeEnum.UNKNOWN - }, - page = 0, - size = 20, - ), - ).onSuccess { - sdRepository.observeSalesOrderList().collect { pageResponse -> - _orderList.value = pageResponse - } - }.onFailure { e -> + _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) } - } catch (e: Exception) { - Timber.e(e, "주문 목록 로드 실패") - } finally { - _isLoading.value = false - } } } - fun updateSearchQuery(query: String) { - _searchQuery.value = query + 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() + 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..7edff2f --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt @@ -0,0 +1,11 @@ +package com.autoever.everp.ui.customer + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +@Composable +fun CustomerProfileEditScreen( + navController: NavHostController +) { + +} 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 f5ee602..cab614c 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 @@ -13,6 +13,7 @@ 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 @@ -32,6 +33,7 @@ import androidx.navigation.NavController @Composable fun CustomerProfileScreen( + loginNavController: NavController, navController: NavController, viewModel: CustomerProfileViewModel = hiltViewModel(), ) { @@ -164,6 +166,19 @@ fun CustomerProfileScreen( ) } } + + Button( + onClick = { + viewModel.logout { + loginNavController.navigate("login") { + popUpTo(0) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.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 index 20b9182..7389463 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt @@ -2,6 +2,7 @@ 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.customer.CustomerDetail import com.autoever.everp.domain.model.user.UserInfo import com.autoever.everp.domain.repository.SdRepository @@ -16,6 +17,7 @@ import javax.inject.Inject @HiltViewModel class CustomerProfileViewModel @Inject constructor( + private val sessionManager: SessionManager, private val userRepository: UserRepository, private val sdRepository: SdRepository, ) : ViewModel() { @@ -58,5 +60,18 @@ class CustomerProfileViewModel @Inject constructor( 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 a6522c7..e614dce 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 @@ -69,7 +69,7 @@ fun CustomerQuotationScreen( fontWeight = FontWeight.Bold, ) Button( - onClick = { /* TODO: 견적 요청 화면으로 이동 */ }, + onClick = { navController.navigate(CustomerSubNavigationItem.QuotationCreateItem.route) }, modifier = Modifier.padding(top = 8.dp), ) { Text("견적 요청") @@ -148,7 +148,11 @@ fun CustomerQuotationScreen( ) } }, - onClick = { /* 상세 화면 */ }, + onClick = { + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = quotation.id) + ) + }, ) } 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 index 5ccfe9c..bac4c38 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerQuotationViewModel.kt @@ -66,18 +66,24 @@ class CustomerQuotationViewModel @Inject constructor( sdRepository.refreshQuotationList(searchParams.value) .onSuccess { - sdRepository.observeQuotationList().collect { pageResponse -> - if (append) { - // 페이지네이션: 기존 리스트에 추가 - _quotationList.value = _quotationList.value + pageResponse.content - } else { - // 새로운 검색: 리스트 교체 - _quotationList.value = pageResponse.content + // 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) } - _totalPages.value = pageResponse.page.totalPages - _hasMore.value = !pageResponse.page.hasNext - _uiState.value = UiResult.Success(Unit) - } } .onFailure { e -> Timber.e(e, "견적서 목록 로드 실패") 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 85679a8..3e2819c 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 @@ -137,7 +137,13 @@ fun CustomerVoucherScreen( ) } }, - onClick = { /* TODO: 전표 상세 화면으로 이동 */ }, + onClick = { + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = invoice.id + ), + ) + }, modifier = Modifier.weight(1f), ) } 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 index a40e340..6b02874 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt @@ -22,16 +22,20 @@ class CustomerVoucherViewModel @Inject constructor( private val _invoiceList = MutableStateFlow>( PageResponse.empty(), ) - val invoiceList: StateFlow> = _invoiceList.asStateFlow() + val invoiceList: StateFlow> + get() = _invoiceList.asStateFlow() private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() + val searchQuery: StateFlow + get() = _searchQuery.asStateFlow() private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) - val selectedInvoiceIds: StateFlow> = _selectedInvoiceIds.asStateFlow() + val selectedInvoiceIds: StateFlow> + get() = _selectedInvoiceIds.asStateFlow() private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() + val isLoading: StateFlow + get() = _isLoading.asStateFlow() init { loadInvoices() @@ -48,8 +52,15 @@ class CustomerVoucherViewModel @Inject constructor( size = 20, ), ).onSuccess { - fcmRepository.observeApInvoiceList().collect { pageResponse -> + fcmRepository.getApInvoiceList( + InvoiceListParams( + page = 0, + size = 20, + ), + ).onSuccess { pageResponse -> _invoiceList.value = pageResponse + }.onFailure { e -> + Timber.e(e, "매입전표 목록 조회 실패") } }.onFailure { e -> Timber.e(e, "매입전표 목록 로드 실패") 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..c1fb1bb --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt @@ -0,0 +1,331 @@ +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.Divider +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 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() + + 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, + ) + } + } + } + } + } + } + + else -> {} + } + } +} + +@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..90d2a35 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt @@ -0,0 +1,67 @@ +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() + + 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) + } +} + 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..d957bd2 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt @@ -0,0 +1,344 @@ +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.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.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.ArrowDropDown +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.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 java.time.LocalDate +import java.time.format.DateTimeFormatter + +@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) } + + 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), + ) + Box { + OutlinedTextField( + value = "", + onValueChange = {}, + readOnly = true, + label = { Text("품목을 선택하세요") }, + modifier = Modifier + .fillMaxWidth() + .clickable { showItemDropdown = true }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "품목 선택", + ) + }, + ) + DropdownMenu( + expanded = showItemDropdown, + onDismissRequest = { showItemDropdown = false }, + modifier = Modifier.fillMaxWidth(), + ) { + 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..de0ce38 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt @@ -0,0 +1,144 @@ +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 { + loadItems() + // Flow에서 items 업데이트 + imRepository.observeItemToggleList() + .onEach { itemList -> + _items.value = itemList ?: emptyList() + } + .launchIn(viewModelScope) + } + + fun loadItems() { + viewModelScope.launch { + _uiState.value = UiResult.Loading + imRepository.refreshItemToggleList() + .onSuccess { + _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..0ba6ef8 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt @@ -0,0 +1,369 @@ +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), + ) + DetailRow( + label = "결제조건", + value = "외상 30일", // TODO: 실제 데이터에서 가져오기 + ) + DetailRow( + label = "납품조건", + value = "배송", // TODO: 실제 데이터에서 가져오기 + ) + DetailRow( + label = "보증기간", + value = "1년", // TODO: 실제 데이터에서 가져오기 + ) + } + } + + // 견적 품목 카드 + 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) + } +} + From c85545bb7492270beb4bf1ceb3130080dc2e6237 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 16:43:03 +0900 Subject: [PATCH 40/70] =?UTF-8?q?feat(ui):=20=ED=99=94=EB=A9=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EC=9C=84=ED=95=9C=20CustomerNavigationIte?= =?UTF-8?q?m=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoever/everp/ui/customer/CustomerApp.kt | 66 ++++++++++++++++++- .../everp/ui/customer/CustomerHomeScreen.kt | 8 +-- .../ui/customer/CustomerNavigationItem.kt | 43 +++++++++++- 3 files changed, 111 insertions(+), 6 deletions(-) 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 ed0d252..8fcf3f0 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,14 +1,19 @@ 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.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 @@ -26,6 +31,7 @@ fun CustomerApp( startDestination = CustomerNavigationItem.Home.route, modifier = Modifier.padding(innerPadding), ) { + // 고객사 메인 네비게이션 아이템들 composable(CustomerNavigationItem.Home.route) { CustomerHomeScreen(navController = navController) // 고객사 홈 화면 } @@ -40,7 +46,65 @@ fun CustomerApp( } composable(CustomerNavigationItem.Profile.route) { // 공통 프로필 화면을 호출할 수도 있음 (역할을 넘겨주거나 ViewModel 공유) - CustomerProfileScreen(navController = navController) // 고객사 프로필 화면 + 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) } } } 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 8b20de3..442ec59 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 @@ -85,28 +85,28 @@ fun CustomerHomeScreen( QuickActionCard( icon = QuickActionIcons.QuotationRequest, label = "견적 요청", - onClick = { /* TODO: 견적 요청 화면으로 이동 */ }, + onClick = { navController.navigate(CustomerSubNavigationItem.QuotationCreateItem.route) }, ) } item { QuickActionCard( icon = QuickActionIcons.QuotationList, label = "견적 목록", - onClick = { navController.navigate("customer_quotation") }, + onClick = { navController.navigate(CustomerNavigationItem.Quotation.route) }, ) } item { QuickActionCard( icon = QuickActionIcons.PurchaseOrderList, label = "주문 관리", - onClick = { navController.navigate("customer_order") }, + onClick = { navController.navigate(CustomerNavigationItem.SalesOrder.route) }, ) } item { QuickActionCard( icon = QuickActionIcons.InvoiceList, label = "매입전표", - onClick = { navController.navigate("customer_voucher") }, + onClick = { navController.navigate(CustomerNavigationItem.Invoice.route) }, ) } } 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 8fa08a2..72a6119 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,7 +25,8 @@ sealed class CustomerNavigationItem( object Quotation : CustomerNavigationItem("customer_quotation", "견적", Icons.Outlined.RequestPage, Icons.Filled.RequestPage) - object SalesOrder : CustomerNavigationItem("customer_sales_order", "주문", Icons.Outlined.ShoppingBag, Icons.Filled.ShoppingBag) + object SalesOrder : + CustomerNavigationItem("customer_sales_order", "주문", Icons.Outlined.ShoppingBag, Icons.Filled.ShoppingBag) object Invoice : CustomerNavigationItem("customer_invoice", "전표", Icons.Outlined.Receipt, Icons.Filled.Receipt) @@ -35,3 +36,43 @@ sealed class CustomerNavigationItem( 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", "프로필 수정") +} From 8c94f92ead2a210dcef32e50790acd3ae115d850 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:37:45 +0900 Subject: [PATCH 41/70] =?UTF-8?q?feat(dashboard):=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EB=B0=98=ED=99=98=20Dto=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20domain=20model=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DashboardHttpRemoteDataSourceImpl.kt | 12 +- .../remote/http/service/DashboardApi.kt | 19 +- .../remote/mapper/DashboardMapper.kt | 13 +- .../everp/domain/model/dashboard/Dashboard.kt | 13 +- .../model/dashboard/DashboardTapEnum.kt | 246 ++++++++++++++++++ 5 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/domain/model/dashboard/DashboardTapEnum.kt 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 index bae5333..d089a16 100644 --- 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 @@ -4,15 +4,21 @@ 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 = + override suspend fun getDashboardWorkflows( + role: UserRoleEnum, + ): Result = withContext(Dispatchers.IO) { runCatching { - dashboardApi.getDashboardWorkflows(role) + 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/service/DashboardApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt index 0807aa1..0d9d4b9 100644 --- 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 @@ -1,6 +1,7 @@ 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.user.UserRoleEnum import com.autoever.everp.utils.serializer.LocalDateSerializer import kotlinx.serialization.SerialName @@ -17,7 +18,7 @@ interface DashboardApi { @GET("$BASE_URL/workflows") suspend fun getDashboardWorkflows( @Query("role") role: UserRoleEnum, - ): DashboardWorkflowsResponseDto + ): ApiResponse companion object { private const val BASE_URL = "dashboard" @@ -26,8 +27,6 @@ interface DashboardApi { @Serializable data class DashboardWorkflowsResponseDto( - @SerialName("role") - val role: String, @SerialName("tabs") val tabs: List, ) { @@ -41,18 +40,18 @@ data class DashboardWorkflowsResponseDto( @Serializable data class DashboardWorkflowTabItemDto( @SerialName("itemId") - val workflowId: String, + val itemId: String, @SerialName("itemNumber") - val count: Int, + val itemNumber: String, @SerialName("itemTitle") - val workflowName: String, + val itemTitle: String, // workflow name @SerialName("name") - val name: String, + val name: String, // 고객명 or 공급사명 @SerialName("statusCode") val statusCode: String, - @SerialName("data") + @SerialName("date") @Serializable(with = LocalDateSerializer::class) - val data: LocalDate, + val date: LocalDate, ) } } @@ -62,5 +61,5 @@ data class DashboardWorkflowsResponseDto( @GET("$BASE_URL/statistics") suspend fun getDashboardStatistics(): DashboardStatisticsResponseDto - + */ 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 index 10e10f4..dc69d9b 100644 --- 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 @@ -6,18 +6,17 @@ import com.autoever.everp.domain.model.dashboard.DashboardWorkflows object DashboardMapper { fun toDomain(dto: DashboardWorkflowsResponseDto): DashboardWorkflows = DashboardWorkflows( - role = dto.role, tabs = dto.tabs.map { tab -> DashboardWorkflows.DashboardWorkflowTab( tabCode = tab.tabCode, items = tab.items.map { item -> DashboardWorkflows.DashboardWorkflowItem( - workflowId = item.workflowId, - count = item.count, - workflowName = item.workflowName, - name = item.name, - statusCode = item.statusCode, - date = item.data, + 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/domain/model/dashboard/Dashboard.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt index 148139f..95b3b5a 100644 --- 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 @@ -3,7 +3,6 @@ package com.autoever.everp.domain.model.dashboard import java.time.LocalDate data class DashboardWorkflows( - val role: String, val tabs: List, ) { data class DashboardWorkflowTab( @@ -12,12 +11,12 @@ data class DashboardWorkflows( ) data class DashboardWorkflowItem( - val workflowId: String, - val count: Int, - val workflowName: String, - val name: String, - val statusCode: String, - val date: LocalDate, + val id: String, + val number: String, + val description: String, + val createdBy: String, + val status: String, + val createdAt: LocalDate, ) } 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..e906c50 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/domain/model/dashboard/DashboardTapEnum.kt @@ -0,0 +1,246 @@ +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 + } + + 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 + } + } +} From 9485a2d9996270056f192e3a3a38ec2f032bcf95 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:39:23 +0900 Subject: [PATCH 42/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC&?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EC=82=AC=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/customer/CustomerHomeScreen.kt | 82 ++++++++++++------ .../ui/customer/CustomerHomeViewModel.kt | 43 +++++++--- .../everp/ui/supplier/SupplierHomeScreen.kt | 76 +++++++++++------ .../ui/supplier/SupplierHomeViewModel.kt | 83 +++++++------------ 4 files changed, 171 insertions(+), 113 deletions(-) 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 442ec59..185e8d5 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 @@ -18,7 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -36,8 +35,8 @@ fun CustomerHomeScreen( navController: NavController, viewModel: CustomerHomeViewModel = hiltViewModel(), ) { - val recentQuotations by viewModel.recentQuotations.collectAsStateWithLifecycle() - val recentOrders by viewModel.recentOrders.collectAsStateWithLifecycle() + val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() + val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() LazyColumn( @@ -125,28 +124,17 @@ fun CustomerHomeScreen( Text(text = "로딩 중...") } } else { - // 견적서 활동 - recentQuotations.forEach { quotation -> + recentActivities.forEach { activity -> item { + val category = categoryMap[activity.id] ?: "" RecentActivityCard( - status = quotation.status.displayName(), - statusColor = quotation.status.toColor(), - title = "${quotation.number} - ${quotation.product.productId}", - date = quotation.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { /* TODO: 견적 상세 화면으로 이동 */ }, - ) - } - } - - // 주문서 활동 - recentOrders.forEach { order -> - item { - RecentActivityCard( - status = order.statusCode.displayName(), - statusColor = order.statusCode.toColor(), - title = "${order.salesOrderNumber} - ${order.customerName}", - date = order.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { /* TODO: 주문 상세 화면으로 이동 */ }, + category = getCategoryDisplayName(category), + status = activity.status, + title = activity.description, + date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { + navigateToDetail(navController, category, activity.id) + }, ) } } @@ -156,8 +144,8 @@ fun CustomerHomeScreen( @Composable private fun RecentActivityCard( + category: String, status: String, - statusColor: androidx.compose.ui.graphics.Color, title: String, date: String, onClick: () -> Unit, @@ -181,9 +169,15 @@ private fun RecentActivityCard( 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 = statusColor, + color = androidx.compose.ui.graphics.Color(0xFF9E9E9E), ) Column { Text( @@ -206,3 +200,41 @@ private fun RecentActivityCard( } } } + +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()) { + "QUOTATION", "견적" -> { + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = workflowId) + ) + } + "ORDER", "주문", "SALES_ORDER" -> { + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(workflowId) + ) + } + "INVOICE", "전표", "AP_INVOICE", "AR_INVOICE" -> { + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = workflowId, + isAp = category.contains("AP", ignoreCase = true), + ), + ) + } + // Customer 화면에서는 발주 상세로 이동하지 않음 + } +} 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 index b0b0ea9..57b4653 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -2,10 +2,9 @@ package com.autoever.everp.ui.customer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.autoever.everp.domain.repository.SdRepository -import com.autoever.everp.domain.model.quotation.QuotationListItem -import com.autoever.everp.domain.model.quotation.QuotationListParams -import com.autoever.everp.domain.model.sale.SalesOrderListItem +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +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 @@ -16,15 +15,17 @@ import javax.inject.Inject @HiltViewModel class CustomerHomeViewModel @Inject constructor( - private val sdRepository: SdRepository, + private val dashboardRepository: DashboardRepository, + private val userRepository: UserRepository, ) : ViewModel() { - private val _recentQuotations = MutableStateFlow>(emptyList()) - val recentQuotations: StateFlow> - get() = _recentQuotations.asStateFlow() + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> + get() = _recentActivities.asStateFlow() - private val _recentOrders = MutableStateFlow>(emptyList()) - val recentOrders: StateFlow> = _recentOrders.asStateFlow() + private val _categoryMap = MutableStateFlow>(emptyMap()) + val categoryMap: StateFlow> + get() = _categoryMap.asStateFlow() private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -37,7 +38,29 @@ class CustomerHomeViewModel @Inject constructor( viewModelScope.launch { _isLoading.value = true try { + userRepository.getUserInfo().onSuccess { userInfo -> + val role = userInfo.userRole + dashboardRepository.refreshWorkflows(role).onSuccess { + dashboardRepository.getWorkflows(role).onSuccess { workflows -> + // 모든 tabs의 items를 하나의 리스트로 합치고 날짜순으로 정렬 + val allItems = workflows.tabs.flatMap { tab -> + tab.items.map { item -> + item to tab.tabCode + } + }.sortedByDescending { it.first.createdAt } + .take(10) // 최근 10개만 + _recentActivities.value = allItems.map { it.first } + _categoryMap.value = allItems.associate { it.first.id to it.second } + }.onFailure { e -> + Timber.e(e, "워크플로우 조회 실패") + } + }.onFailure { e -> + Timber.e(e, "워크플로우 갱신 실패") + } + }.onFailure { e -> + Timber.e(e, "사용자 정보 조회 실패") + } } catch (e: Exception) { Timber.e(e, "최근 활동 로드 실패") } finally { 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 855bdb9..318f225 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 @@ -18,7 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -36,8 +35,8 @@ fun SupplierHomeScreen( navController: NavController, viewModel: SupplierHomeViewModel = hiltViewModel(), ) { - val recentPurchaseOrders by viewModel.recentPurchaseOrders.collectAsStateWithLifecycle() - val recentInvoices by viewModel.recentInvoices.collectAsStateWithLifecycle() + val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() + val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() LazyColumn( @@ -111,28 +110,17 @@ fun SupplierHomeScreen( Text(text = "로딩 중...") } } else { - // 발주서 활동 - recentPurchaseOrders.forEach { purchaseOrder -> + recentActivities.forEach { activity -> item { + val category = categoryMap[activity.id] ?: "" RecentActivityCard( - status = purchaseOrder.status.displayName(), - statusColor = purchaseOrder.status.toColor(), - title = "${purchaseOrder.number} - ${purchaseOrder.itemsSummary}", - date = purchaseOrder.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { /* TODO: 발주 상세 화면으로 이동 */ }, - ) - } - } - - // 전표 활동 - recentInvoices.forEach { invoice -> - item { - RecentActivityCard( - status = invoice.status.displayName(), - statusColor = invoice.status.toColor(), - title = "${invoice.number} - ${invoice.connection.name}", - date = invoice.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { /* TODO: 전표 상세 화면으로 이동 */ }, + category = getCategoryDisplayName(category), + status = activity.status, + title = activity.description, + date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + onClick = { + navigateToDetail(navController, category, activity.id) + }, ) } } @@ -142,8 +130,8 @@ fun SupplierHomeScreen( @Composable private fun RecentActivityCard( + category: String, status: String, - statusColor: androidx.compose.ui.graphics.Color, title: String, date: String, onClick: () -> Unit, @@ -167,9 +155,15 @@ private fun RecentActivityCard( 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 = statusColor, + color = androidx.compose.ui.graphics.Color(0xFF9E9E9E), ) Column { Text( @@ -192,3 +186,35 @@ private fun RecentActivityCard( } } } + +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/supplier/SupplierHomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt index 7589140..d9d173f 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -2,12 +2,9 @@ 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.invoice.InvoiceListItem -import com.autoever.everp.domain.model.invoice.InvoiceListParams -import com.autoever.everp.domain.repository.MmRepository -import com.autoever.everp.domain.repository.FcmRepository +import com.autoever.everp.domain.model.dashboard.DashboardWorkflows +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 @@ -18,21 +15,20 @@ import javax.inject.Inject @HiltViewModel class SupplierHomeViewModel @Inject constructor( - private val mmRepository: MmRepository, - private val fcmRepository: FcmRepository, + private val dashboardRepository: DashboardRepository, + private val userRepository: UserRepository, ) : ViewModel() { - private val _recentPurchaseOrders = MutableStateFlow>(emptyList()) - val recentPurchaseOrders: StateFlow> - get() = _recentPurchaseOrders.asStateFlow() + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> + get() = _recentActivities.asStateFlow() - private val _recentInvoices = MutableStateFlow>(emptyList()) - val recentInvoices: StateFlow> - get() = _recentInvoices.asStateFlow() + private val _categoryMap = MutableStateFlow>(emptyMap()) + val categoryMap: StateFlow> + get() = _categoryMap.asStateFlow() private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow - get() = _isLoading.asStateFlow() + val isLoading: StateFlow = _isLoading.asStateFlow() init { loadRecentActivities() @@ -42,46 +38,28 @@ class SupplierHomeViewModel @Inject constructor( viewModelScope.launch { _isLoading.value = true try { - // 최근 발주서 3개 로드 - mmRepository.refreshPurchaseOrderList( - PurchaseOrderListParams( - page = 0, - size = 3, - ), - ).onSuccess { - mmRepository.getPurchaseOrderList( - PurchaseOrderListParams( - page = 0, - size = 3, - ), - ).onSuccess { pageResponse -> - _recentPurchaseOrders.value = pageResponse.content - }.onFailure { e -> - Timber.e(e, "최근 발주서 로드 실패") - } - }.onFailure { e -> - Timber.e(e, "최근 발주서 갱신 실패") - } + userRepository.getUserInfo().onSuccess { userInfo -> + val role = userInfo.userRole + dashboardRepository.refreshWorkflows(role).onSuccess { + dashboardRepository.getWorkflows(role).onSuccess { workflows -> + // 모든 tabs의 items를 하나의 리스트로 합치고 날짜순으로 정렬 + val allItems = workflows.tabs.flatMap { tab -> + tab.items.map { item -> + item to tab.tabCode + } + }.sortedByDescending { it.first.createdAt } + .take(10) // 최근 10개만 - // 최근 전표 3개 로드 (공급업체는 AR 인보이스 조회) - fcmRepository.refreshArInvoiceList( - InvoiceListParams( - page = 0, - size = 3, - ), - ).onSuccess { - fcmRepository.getArInvoiceList( - InvoiceListParams( - page = 0, - size = 3, - ), - ).onSuccess { pageResponse -> - _recentInvoices.value = pageResponse.content + _recentActivities.value = allItems.map { it.first } + _categoryMap.value = allItems.associate { it.first.id to it.second } + }.onFailure { e -> + Timber.e(e, "워크플로우 조회 실패") + } }.onFailure { e -> - Timber.e(e, "최근 전표 로드 실패") + Timber.e(e, "워크플로우 갱신 실패") } }.onFailure { e -> - Timber.e(e, "최근 전표 갱신 실패") + Timber.e(e, "사용자 정보 조회 실패") } } catch (e: Exception) { Timber.e(e, "최근 활동 로드 실패") @@ -95,4 +73,3 @@ class SupplierHomeViewModel @Inject constructor( loadRecentActivities() } } - From ca8e5ba5b2f326e4ffda00aaf54b36d88897daba Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:42:46 +0900 Subject: [PATCH 43/70] =?UTF-8?q?feat(data):=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=85=9C=20=ED=86=A0=EA=B8=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=98=ED=99=98=EA=B0=92=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http/impl/ImHttpRemoteDataSourceImpl.kt | 30 +++++++++++-------- .../datasource/remote/http/service/ImApi.kt | 15 ++++++++-- .../data/datasource/remote/mapper/ImMapper.kt | 6 ++-- 3 files changed, 33 insertions(+), 18 deletions(-) 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 index dc8d7a1..0d6cf63 100644 --- 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 @@ -15,19 +15,25 @@ class ImHttpRemoteDataSourceImpl @Inject constructor( override suspend fun getItemsToggle( ): Result> = withContext(Dispatchers.IO) { - runCatching { - val data = imApi.getItemsToggle().data - - data?.map { dto -> - InventoryItemToggle( - itemId = dto.itemId, - itemName = dto.itemName, - uomName = dto.uomName, - unitPrice = dto.unitPrice, -// supplierCompanyId = dto.supplierCompanyId, -// supplierCompanyName = dto.supplierCompanyName, + 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 ?: "품목 목록 조회 실패") ) - } ?: emptyList() + } + } catch (e: Exception) { + Result.failure(e) } } } 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 6f12237..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 @@ -11,10 +11,13 @@ import retrofit2.http.GET */ interface ImApi { + /** + * 재고 아이템 토글 목록 조회 + */ @GET("scm-pp/product/item/toggle") suspend fun getItemsToggle( - ): ApiResponse> + ): ApiResponse companion object { private const val BASE_URL = "scm-pp/iv" @@ -22,7 +25,13 @@ interface ImApi { } @Serializable -data class ItemToggleResponseDto( +data class ItemToggleListResponseDto( + @SerialName("products") + val products: List, +) + +@Serializable +data class ItemToggleListItemDto( @SerialName("itemId") val itemId: String, @SerialName("itemNumber") @@ -32,7 +41,7 @@ data class ItemToggleResponseDto( @SerialName("uomName") val uomName: String, @SerialName("unitPrice") - val unitPrice: Long, + val unitPrice: Double, // @SerialName("supplierCompanyId") // val supplierCompanyId: String, // @SerialName("supplierCompanyName") 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 index 6a63bd3..c95d62e 100644 --- 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 @@ -1,15 +1,15 @@ package com.autoever.everp.data.datasource.remote.mapper -import com.autoever.everp.data.datasource.remote.http.service.ItemToggleResponseDto +import com.autoever.everp.data.datasource.remote.http.service.ItemToggleListItemDto import com.autoever.everp.domain.model.inventory.InventoryItemToggle object ImMapper { - fun toDomain(dto: ItemToggleResponseDto): InventoryItemToggle = + fun toDomain(dto: ItemToggleListItemDto): InventoryItemToggle = InventoryItemToggle( itemId = dto.itemId, itemName = dto.itemName, uomName = dto.uomName, - unitPrice = dto.unitPrice, + unitPrice = dto.unitPrice.toLong(), // supplierCompanyId = dto.supplierCompanyId, // supplierCompanyName = dto.supplierCompanyName, ) From 9e380092773d8dcafddcf1e6ede6e49199232af4 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:44:16 +0900 Subject: [PATCH 44/70] =?UTF-8?q?feat(data):=20=EA=B2=AC=EC=A0=81=EC=84=9C?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1,=20=EA=B2=AC=EC=A0=81=EC=84=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20Dto=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/remote/http/service/SdApi.kt | 8 ++++---- .../data/datasource/remote/mapper/SdMapper.kt | 18 ++++++++++++++++-- .../model/quotation/QuotationCreateRequest.kt | 2 +- .../model/quotation/QuotationListItem.kt | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) 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..adb3a5a 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") @@ -172,7 +172,7 @@ data class QuotationItemDto( data class QuotationCreateRequestDto( @Serializable(with = LocalDateSerializer::class) @SerialName("dueDate") - val dueDate: LocalDate, + val dueDate: LocalDate? = null, @SerialName("items") val items: List, @SerialName("note") 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..1836a2c 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, ) } @@ -153,5 +154,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/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( From 06132a4bbc222dbb16dbd6a94e582dbe48f84553 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:46:50 +0900 Subject: [PATCH 45/70] =?UTF-8?q?refact():=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoever/everp/data/datasource/remote/ImRemoteDataSource.kt | 1 - .../datasource/remote/http/impl/ImHttpRemoteDataSourceImpl.kt | 1 - 2 files changed, 2 deletions(-) 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 index 1788b20..c025ede 100644 --- 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 @@ -1,6 +1,5 @@ package com.autoever.everp.data.datasource.remote -import com.autoever.everp.data.datasource.remote.http.service.ItemToggleResponseDto import com.autoever.everp.domain.model.inventory.InventoryItemToggle interface ImRemoteDataSource { 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 index 0d6cf63..b5d41ce 100644 --- 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 @@ -2,7 +2,6 @@ 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.data.datasource.remote.http.service.ItemToggleResponseDto import com.autoever.everp.domain.model.inventory.InventoryItemToggle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext From 32c683170bcbcc4035eb1d209455f811ddea3082 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Sun, 9 Nov 2025 18:47:55 +0900 Subject: [PATCH 46/70] =?UTF-8?q?feat(ui):=20=EA=B2=AC=EC=A0=81=EC=84=9C?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=ED=86=A0=EA=B8=80=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/customer/CustomerQuotationScreen.kt | 4 +- .../ui/customer/QuotationCreateScreen.kt | 67 +++++++++++++------ .../ui/customer/QuotationCreateViewModel.kt | 29 ++++++-- 3 files changed, 73 insertions(+), 27 deletions(-) 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 e614dce..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 @@ -140,9 +140,9 @@ fun CustomerQuotationScreen( ) Text( text = "납기일: ${ - quotation.dueDate.format( + quotation.dueDate?.format( DateTimeFormatter.ofPattern("yyyy-MM-dd") - ) + ) ?: "미정" }", style = MaterialTheme.typography.bodyMedium, ) 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 index d957bd2..1b03895 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateScreen.kt @@ -3,7 +3,6 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,21 +14,23 @@ 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.ArrowDropDown 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.DropdownMenu 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 @@ -44,9 +45,11 @@ 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, @@ -62,6 +65,10 @@ fun QuotationCreateScreen( 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() @@ -145,35 +152,43 @@ fun QuotationCreateScreen( style = androidx.compose.material3.MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp), ) - Box { + ExposedDropdownMenuBox( + expanded = showItemDropdown, + onExpandedChange = { showItemDropdown = it }, + ) { OutlinedTextField( value = "", onValueChange = {}, readOnly = true, label = { Text("품목을 선택하세요") }, - modifier = Modifier - .fillMaxWidth() - .clickable { showItemDropdown = true }, trailingIcon = { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "품목 선택", - ) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = showItemDropdown) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), ) - DropdownMenu( + ExposedDropdownMenu( expanded = showItemDropdown, onDismissRequest = { showItemDropdown = false }, - modifier = Modifier.fillMaxWidth(), ) { - items.forEach { item -> + if (items.isEmpty()) { DropdownMenuItem( - text = { Text("${item.itemName} (${item.uomName}) - ${formatCurrency(item.unitPrice)}") }, - onClick = { - viewModel.addItem(item) - showItemDropdown = false - }, + 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 + }, + ) + } } } } @@ -246,7 +261,12 @@ fun QuotationCreateScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { viewModel.updateQuantity(selectedItem.item.itemId, selectedItem.quantity - 1) }, + onClick = { + viewModel.updateQuantity( + selectedItem.item.itemId, + selectedItem.quantity - 1 + ) + }, enabled = selectedItem.quantity > 1, ) { Text("-") @@ -257,7 +277,12 @@ fun QuotationCreateScreen( modifier = Modifier.padding(horizontal = 16.dp), ) TextButton( - onClick = { viewModel.updateQuantity(selectedItem.item.itemId, selectedItem.quantity + 1) }, + onClick = { + viewModel.updateQuantity( + selectedItem.item.itemId, + selectedItem.quantity + 1 + ) + }, ) { 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 index de0ce38..b9d048f 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationCreateViewModel.kt @@ -50,13 +50,19 @@ class QuotationCreateViewModel @Inject constructor( get() = _selected.value.values.sumOf { it.totalPrice } init { - loadItems() - // Flow에서 items 업데이트 + // Flow에서 items 업데이트를 먼저 구독 imRepository.observeItemToggleList() .onEach { itemList -> - _items.value = itemList ?: emptyList() + _items.value = itemList + // 데이터가 로드되면 UI 상태 업데이트 + if (itemList.isNotEmpty() && _uiState.value is UiResult.Loading) { + _uiState.value = UiResult.Success(Unit) + } } .launchIn(viewModelScope) + + // 초기 데이터 로드 + loadItems() } fun loadItems() { @@ -64,7 +70,22 @@ class QuotationCreateViewModel @Inject constructor( _uiState.value = UiResult.Loading imRepository.refreshItemToggleList() .onSuccess { - _uiState.value = UiResult.Success(Unit) + // 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) From 1644be0a5ff68944231f4e4bef785b89f18cb3cd Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 11:52:04 +0900 Subject: [PATCH 47/70] =?UTF-8?q?feat(ui):=20DetailScreen=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=82=AC=ED=95=AD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/customer/QuotationDetailScreen.kt | 36 +---------- .../ui/supplier/PurchaseOrderDetailScreen.kt | 59 ++++++------------- 2 files changed, 19 insertions(+), 76 deletions(-) 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 index 0ba6ef8..6e02e8d 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/QuotationDetailScreen.kt @@ -148,7 +148,7 @@ fun QuotationDetailScreen( value = detail.issueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), ) DetailRow( - label = "유효기간", + label = "납기일자", value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), ) DetailRow( @@ -190,40 +190,6 @@ fun QuotationDetailScreen( } } - // 견적 조건 카드 - 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 = "외상 30일", // TODO: 실제 데이터에서 가져오기 - ) - DetailRow( - label = "납품조건", - value = "배송", // TODO: 실제 데이터에서 가져오기 - ) - DetailRow( - label = "보증기간", - value = "1년", // TODO: 실제 데이터에서 가져오기 - ) - } - } - // 견적 품목 카드 Card( modifier = Modifier.fillMaxWidth(), 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 index 88fbf86..c89b45d 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/PurchaseOrderDetailScreen.kt @@ -215,12 +215,6 @@ fun PurchaseOrderDetailScreen( fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f), ) - Text( - text = "규격", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1.5f), - ) Text( text = "수량", style = MaterialTheme.typography.bodySmall, @@ -267,11 +261,6 @@ fun PurchaseOrderDetailScreen( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f), ) - Text( - text = item.uomName, // 규격은 uomName으로 대체 - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1.5f), - ) Text( text = "${item.quantity}", style = MaterialTheme.typography.bodyMedium, @@ -323,37 +312,25 @@ fun PurchaseOrderDetailScreen( 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), + if (detail.note.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { - Text( - text = "배송 및 메모", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp), - ) - DetailRow( - label = "배송 창고", - value = "본사 창고", // TODO: 실제 데이터에서 가져오기 - ) - DetailRow( - label = "요청 배송일", - value = detail.dueDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - ) - DetailRow( - label = "특별 지시사항", - value = "오전 배송 요청", // TODO: 실제 데이터에서 가져오기 - ) - if (detail.note.isNotEmpty()) { + 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, From e58a18adc08dd894dfb2b96a25665320a2f829d4 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 15:19:59 +0900 Subject: [PATCH 48/70] =?UTF-8?q?feat(dto):=20API=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EA=B0=92=20=EB=B0=8F=20=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/remote/http/service/FcmApi.kt | 16 ++-- .../datasource/remote/http/service/MmApi.kt | 16 ++-- .../datasource/remote/http/service/SdApi.kt | 12 +-- .../datasource/remote/mapper/InvoiceMapper.kt | 8 +- .../remote/mapper/PurchaseOrderMapper.kt | 12 +-- .../data/datasource/remote/mapper/SdMapper.kt | 10 +-- .../domain/model/invoice/InvoiceStatusEnum.kt | 30 +++---- .../model/purchase/PurchaseOrderStatusEnum.kt | 4 +- .../model/quotation/QuotationStatusEnum.kt | 20 ++--- .../domain/model/sale/SalesOrderStatusEnum.kt | 79 ++++++++++++++++--- 10 files changed, 131 insertions(+), 76 deletions(-) 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..c6e2c56 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 @@ -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, @@ -115,11 +115,11 @@ data class InvoiceListItemDto( @Serializable data class InvoiceConnectionDto( - @SerialName("connectionId") + @SerialName("companyId") val connectionId: String, - @SerialName("connectionNumber") + @SerialName("companyCode") val connectionNumber: String, - @SerialName("connectionName") + @SerialName("companyName") val connectionName: String, ) @@ -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/MmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/MmApi.kt index ea252ec..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 @@ -194,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") @@ -213,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, ) @@ -225,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/SdApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/SdApi.kt index adb3a5a..4855b76 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 @@ -149,7 +149,7 @@ data class QuotationDetailResponseDto( @SerialName("items") val items: List, @SerialName("totalAmount") - val totalAmount: Long, + val totalAmount: Double, ) @Serializable @@ -163,9 +163,9 @@ 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 @@ -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, ) 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/PurchaseOrderMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/PurchaseOrderMapper.kt index b0b1e60..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 @@ -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 1836a2c..6609703 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 @@ -54,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(), ) } @@ -65,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, ) @@ -102,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, ) } @@ -118,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, ) } diff --git a/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt index 547a30c..9d8a841 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt @@ -4,9 +4,9 @@ import androidx.compose.ui.graphics.Color enum class InvoiceStatusEnum { UNKNOWN, // 알 수 없음, 기본값 - UNPAID, // 미지급 - PENDING, // 지급 대기 - PAID, // 지급 완료 + PENDING, // 미지급 + RESPONSE_PENDING, // 지급 대기 + COMPLETED, // 지급 완료 ; /** @@ -15,9 +15,9 @@ enum class InvoiceStatusEnum { fun toKorean(): String = when (this) { UNKNOWN -> "알 수 없음" - UNPAID -> "미지급" - PENDING -> "지급 대기" - PAID -> "지급 완료" + PENDING -> "미지급" + RESPONSE_PENDING -> "지급 대기" + COMPLETED -> "지급 완료" } /** @@ -36,9 +36,9 @@ enum class InvoiceStatusEnum { fun description(): String = when (this) { UNKNOWN -> "알 수 없는 상태" - UNPAID -> "아직 지급되지 않은 청구서" - PENDING -> "지급 처리 중인 청구서" - PAID -> "지급이 완료된 청구서" + PENDING -> "아직 지급되지 않은 청구서" + RESPONSE_PENDING -> "지급 처리 중인 청구서" + COMPLETED -> "지급이 완료된 청구서" } /** @@ -48,9 +48,9 @@ enum class InvoiceStatusEnum { fun toColor(): Color = when (this) { UNKNOWN -> Color(0xFF9E9E9E) // Grey - UNPAID -> Color(0xFFF44336) // Red - PENDING -> Color(0xFFFF9800) // Orange - PAID -> Color(0xFF4CAF50) // Green + PENDING -> Color(0xFFF44336) // Red + RESPONSE_PENDING -> Color(0xFFFF9800) // Orange + COMPLETED -> Color(0xFF4CAF50) // Green } /** @@ -61,17 +61,17 @@ enum class InvoiceStatusEnum { /** * 지급 완료 여부 */ - fun isPaid(): Boolean = this == PAID + fun isPaid(): Boolean = this == COMPLETED /** * 처리 가능 여부 (미지급 또는 대기 상태) */ - fun isProcessable(): Boolean = this == UNPAID || this == PENDING + fun isProcessable(): Boolean = this == PENDING || this == RESPONSE_PENDING /** * 알림이 필요한 상태인지 확인 */ - fun needsAlert(): Boolean = this == UNPAID || this == PENDING + fun needsAlert(): Boolean = this == PENDING || this == RESPONSE_PENDING companion object { /** 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/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/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) } } From 3f7a16c75ce18bd74e66a3523b748a61378f526f Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 16:57:11 +0900 Subject: [PATCH 49/70] =?UTF-8?q?feat(data):=20SdApi=EA=B4=80=EB=A0=A8=20D?= =?UTF-8?q?TO=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/data/datasource/remote/http/service/SdApi.kt | 10 ++++++---- .../everp/data/datasource/remote/mapper/SdMapper.kt | 3 ++- .../com/autoever/everp/domain/model/sale/SalesOrder.kt | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) 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 4855b76..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 @@ -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/mapper/SdMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/SdMapper.kt index 6609703..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 @@ -130,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, @@ -143,6 +143,7 @@ object SdMapper { itemId = it.itemId, itemName = it.itemName, quantity = it.quantity, + uomName = it.uomName, unitPrice = it.unitPrice, totalPrice = it.totalPrice, ) 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, ) { From 1c551cf4fe7701f3c4c2cbe8e79d227a6077cc63 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 17:00:09 +0900 Subject: [PATCH 50/70] =?UTF-8?q?feat(profile):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?data-domain=20=ED=9D=90=EB=A6=84=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileRemoteDataSource - ProfileRepository --- .../remote/ProfileRemoteDataSource.kt | 3 +- .../impl/ProfileHttpRemoteDataSourceImpl.kt | 52 +++++++++++++----- .../remote/http/service/ProfileApi.kt | 54 +++++++++++++++---- .../datasource/remote/mapper/ProfileMapper.kt | 28 +++++++--- .../data/repository/ProfileRepositoryImpl.kt | 10 ++-- .../everp/domain/model/profile/Profile.kt | 20 +++++-- .../domain/repository/ProfileRepository.kt | 5 +- 7 files changed, 129 insertions(+), 43 deletions(-) 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 index aa0ec06..16a4e0d 100644 --- 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 @@ -1,8 +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(): Result + suspend fun getProfile(userType: UserTypeEnum): Result } 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 index b824f73..2fc29a8 100644 --- 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 @@ -1,9 +1,11 @@ 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.ProfileResponseDto +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 @@ -13,21 +15,45 @@ class ProfileHttpRemoteDataSourceImpl @Inject constructor( ) : ProfileRemoteDataSource { override suspend fun getProfile( - + userType: UserTypeEnum, ): Result = withContext(Dispatchers.IO) { runCatching { - val response = profileApi.getProfile() //.data ?: throw Exception("Profile data is null") - response.data?.let { dto: ProfileResponseDto -> - Profile( - businessName = dto.businessName, - businessNumber = dto.businessNumber, - ceoName = dto.ceoName, - address = dto.address, - contactNumber = dto.contactNumber, - ) - } ?: throw Exception("Profile data is null") + 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/service/ProfileApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/ProfileApi.kt index 830b65e..adfd4c3 100644 --- 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 @@ -8,9 +8,15 @@ import retrofit2.http.GET interface ProfileApi { @GET(BASE_URL) - suspend fun getProfile( + suspend fun getCustomerProfile( + + ): ApiResponse + + @GET(BASE_URL) + suspend fun getSupplierProfile( + + ): ApiResponse - ): ApiResponse companion object { private const val BASE_URL = "business/profile" @@ -18,15 +24,41 @@ interface ProfileApi { } @Serializable -data class ProfileResponseDto( - @SerialName("businessName") - val businessName: String, +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("ceoName") - val ceoName: String, - @SerialName("address") - val address: String, - @SerialName("contactNumber") - val contactNumber: 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/mapper/ProfileMapper.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/mapper/ProfileMapper.kt index b799d3e..dd80b5a 100644 --- 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 @@ -1,16 +1,32 @@ package com.autoever.everp.data.datasource.remote.mapper -import com.autoever.everp.data.datasource.remote.http.service.ProfileResponseDto +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: ProfileResponseDto): Profile = + fun toDomain(dto: SupplierProfileResponseDto): Profile = Profile( - businessName = dto.businessName, + userName = dto.supplierUserName, + userEmail = dto.supplierUserEmail, + userPhoneNumber = dto.supplierUserPhoneNumber, + companyName = dto.companyName, businessNumber = dto.businessNumber, - ceoName = dto.ceoName, - address = dto.address, - contactNumber = dto.contactNumber, + 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/repository/ProfileRepositoryImpl.kt b/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt index 83c67fe..512faba 100644 --- a/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt +++ b/app/src/main/java/com/autoever/everp/data/repository/ProfileRepositoryImpl.kt @@ -2,8 +2,8 @@ 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.data.datasource.remote.mapper.ProfileMapper 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 @@ -18,14 +18,14 @@ class ProfileRepositoryImpl @Inject constructor( override fun observeProfile(): Flow = profileLocalDataSource.observeProfile() - override suspend fun refreshProfile(): Result = withContext(Dispatchers.Default) { - getProfile().map { profile -> + override suspend fun refreshProfile(userType: UserTypeEnum): Result = withContext(Dispatchers.Default) { + getProfile(userType).map { profile -> profileLocalDataSource.setProfile(profile) } } - override suspend fun getProfile(): Result = withContext(Dispatchers.Default) { - profileRemoteDataSource.getProfile() + override suspend fun getProfile(userType: UserTypeEnum): Result = withContext(Dispatchers.Default) { + profileRemoteDataSource.getProfile(userType) } } 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 index 84c39b5..e3a95ae 100644 --- 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 @@ -1,10 +1,20 @@ package com.autoever.everp.domain.model.profile data class Profile( - val businessName: String, + val userName: String, + val userEmail: String, + val userPhoneNumber: String, + val companyName: String, val businessNumber: String, - val ceoName: String, - val address: String, - val contactNumber: 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/repository/ProfileRepository.kt b/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt index b398793..aed86fe 100644 --- a/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt +++ b/app/src/main/java/com/autoever/everp/domain/repository/ProfileRepository.kt @@ -1,11 +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(): Result - suspend fun getProfile(): Result + suspend fun refreshProfile(userType: UserTypeEnum): Result + suspend fun getProfile(userType: UserTypeEnum): Result } From d073a7c122935b95829b508ab685b419dbeb6703 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 17:02:11 +0900 Subject: [PATCH 51/70] =?UTF-8?q?feat(ui)=20=EA=B3=B5=EA=B8=89=EC=82=AC=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/supplier/SupplierProfileEditScreen.kt | 71 +++++++++------- .../supplier/SupplierProfileEditViewModel.kt | 84 +++++++------------ .../ui/supplier/SupplierProfileScreen.kt | 26 +++--- .../ui/supplier/SupplierProfileViewModel.kt | 32 ++++--- 4 files changed, 107 insertions(+), 106 deletions(-) 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 index a80e6ac..4e5f8bb 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditScreen.kt @@ -39,23 +39,25 @@ fun SupplierProfileEditScreen( navController: NavController, viewModel: SupplierProfileEditViewModel = hiltViewModel(), ) { - val supplierDetail by viewModel.supplierDetail.collectAsState() + val profile by viewModel.profile.collectAsState() val userInfo by viewModel.userInfo.collectAsState() val isSaving by viewModel.isSaving.collectAsState() var companyName by remember { mutableStateOf("") } - var companyAddress by remember { mutableStateOf("") } - var companyPhone by remember { mutableStateOf("") } - var companyEmail by remember { mutableStateOf("") } - var managerPhone 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(supplierDetail) { - supplierDetail?.let { detail -> - companyName = detail.name - companyAddress = detail.fullAddress - companyPhone = detail.phone - companyEmail = detail.email - managerPhone = detail.manager?.phone ?: "" + LaunchedEffect(profile) { + profile?.let { p -> + companyName = p.companyName + businessNumber = p.businessNumber + baseAddress = p.baseAddress + detailAddress = p.detailAddress + officePhone = p.officePhone + userPhoneNumber = p.userPhoneNumber } } @@ -78,9 +80,9 @@ fun SupplierProfileEditScreen( .verticalScroll(rememberScrollState()) .padding(16.dp), ) { - // 공급업체 정보 섹션 + // 사업자 정보 섹션 Text( - text = "공급업체 정보", + text = "사업자 정보", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp), @@ -104,25 +106,33 @@ fun SupplierProfileEditScreen( .padding(vertical = 4.dp), ) OutlinedTextField( - value = companyAddress, - onValueChange = { companyAddress = it }, - label = { Text("회사 주소") }, + value = businessNumber, + onValueChange = { businessNumber = it }, + label = { Text("사업자등록번호") }, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), ) OutlinedTextField( - value = companyPhone, - onValueChange = { companyPhone = it }, - label = { Text("회사 전화번호") }, + value = baseAddress, + onValueChange = { baseAddress = it }, + label = { Text("기본 주소") }, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), ) OutlinedTextField( - value = companyEmail, - onValueChange = { companyEmail = it }, - label = { Text("회사 이메일") }, + 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), @@ -159,7 +169,7 @@ fun SupplierProfileEditScreen( .padding(vertical = 4.dp), ) OutlinedTextField( - value = userInfo?.email ?: "", + value = profile?.userEmail ?: userInfo?.email ?: "", onValueChange = { /* 이메일은 수정 불가 */ }, label = { Text("이메일 *") }, enabled = false, @@ -168,8 +178,8 @@ fun SupplierProfileEditScreen( .padding(vertical = 4.dp), ) OutlinedTextField( - value = managerPhone, - onValueChange = { managerPhone = it }, + value = userPhoneNumber, + onValueChange = { userPhoneNumber = it }, label = { Text("휴대폰 번호") }, modifier = Modifier .fillMaxWidth() @@ -185,10 +195,11 @@ fun SupplierProfileEditScreen( onClick = { viewModel.saveProfile( companyName = companyName, - companyAddress = companyAddress, - companyPhone = companyPhone, - companyEmail = companyEmail, - managerPhone = managerPhone, + businessNumber = businessNumber, + baseAddress = baseAddress, + detailAddress = detailAddress, + officePhone = officePhone, + userPhoneNumber = userPhoneNumber, onSuccess = { navController.popBackStack() }, 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 index fc2c440..2242d40 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileEditViewModel.kt @@ -2,15 +2,16 @@ package com.autoever.everp.ui.supplier import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.autoever.everp.data.datasource.remote.http.service.SupplierUpdateRequestDto -import com.autoever.everp.domain.model.supplier.SupplierDetail +import com.autoever.everp.domain.model.profile.Profile import com.autoever.everp.domain.model.user.UserInfo -import com.autoever.everp.domain.repository.MmRepository +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 @@ -18,19 +19,26 @@ import javax.inject.Inject @HiltViewModel class SupplierProfileEditViewModel @Inject constructor( private val userRepository: UserRepository, - private val mmRepository: MmRepository, + private val profileRepository: ProfileRepository, ) : ViewModel() { private val _userInfo = MutableStateFlow(null) val userInfo: StateFlow = _userInfo.asStateFlow() - private val _supplierDetail = MutableStateFlow(null) - val supplierDetail: StateFlow = _supplierDetail.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() } @@ -38,68 +46,40 @@ class SupplierProfileEditViewModel @Inject constructor( viewModelScope.launch { userRepository.getUserInfo().onSuccess { user -> _userInfo.value = user - user.userId.let { supplierId -> - mmRepository.getSupplierDetail(supplierId).onSuccess { detail -> - _supplierDetail.value = detail + // 프로필 정보 로드 + profileRepository.refreshProfile(user.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") } - } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") } } } fun saveProfile( companyName: String, - companyAddress: String, - companyPhone: String, - companyEmail: String, - managerPhone: String, + businessNumber: String, + baseAddress: String, + detailAddress: String, + officePhone: String, + userPhoneNumber: String, onSuccess: () -> Unit, ) { viewModelScope.launch { _isSaving.value = true try { - val supplierId = _userInfo.value?.userId ?: return@launch - - val supplierDetail = _supplierDetail.value - ?: return@launch - - val (baseAddress, detailAddress) = parseAddress(companyAddress) - val request = SupplierUpdateRequestDto( - supplierName = companyName, - supplierEmail = companyEmail, - supplierPhone = companyPhone, - supplierBaseAddress = baseAddress, - supplierDetailAddress = detailAddress, - category = supplierDetail.category, - statusCode = supplierDetail.status, - deliveryLeadTime = supplierDetail.deliveryLeadTime, - managerName = supplierDetail.manager.name, - managerPhone = managerPhone, - managerEmail = supplierDetail.manager.email, - ) - - mmRepository.updateSupplier(supplierId, request) - .onSuccess { - Timber.i("프로필 저장 성공") - onSuccess() - } - .onFailure { e -> - Timber.e(e, "프로필 저장 실패") - } + // TODO: ProfileRepository에 updateProfile 메서드가 추가되면 구현 + // 현재는 Profile 정보만 표시하고, 업데이트 기능은 나중에 추가 예정 + Timber.w("프로필 업데이트 기능은 아직 구현되지 않았습니다.") + // 임시로 성공 처리 + onSuccess() + } catch (e: Exception) { + Timber.e(e, "프로필 저장 실패") } finally { _isSaving.value = false } } } - - private fun parseAddress(fullAddress: String): Pair { - // 간단한 주소 파싱: 공백으로 구분하여 첫 번째 부분을 baseAddress, 나머지를 detailAddress로 - val parts = fullAddress.trim().split(" ", limit = 2) - return if (parts.size == 2) { - Pair(parts[0], parts[1].takeIf { it.isNotBlank() }) - } else { - Pair(fullAddress, null) - } - } } 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 903c09b..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 @@ -39,7 +39,7 @@ fun SupplierProfileScreen( viewModel: SupplierProfileViewModel = hiltViewModel(), ) { val userInfo by viewModel.userInfo.collectAsState() - val supplierDetail by viewModel.supplierDetail.collectAsState() + val profile by viewModel.profile.collectAsState() val isLoading by viewModel.isLoading.collectAsState() Column( @@ -100,9 +100,9 @@ fun SupplierProfileScreen( Spacer(modifier = Modifier.height(32.dp)) - // 공급업체 정보 섹션 + // 사업자 정보 섹션 Text( - text = "공급업체 정보", + text = "사업자 정보", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp), @@ -119,19 +119,19 @@ fun SupplierProfileScreen( ) { ProfileField( label = "회사명 *", - value = supplierDetail?.name ?: "", + value = profile?.companyName ?: "", ) ProfileField( - label = "회사 주소", - value = supplierDetail?.fullAddress ?: "", + label = "사업자등록번호", + value = profile?.businessNumber ?: "", ) ProfileField( - label = "회사 전화번호", - value = supplierDetail?.phone ?: "", + label = "주소", + value = profile?.fullAddress ?: "", ) ProfileField( - label = "회사 이메일", - value = supplierDetail?.email ?: "", + label = "회사 전화번호", + value = profile?.officePhone ?: "", ) } } @@ -157,15 +157,15 @@ fun SupplierProfileScreen( ) { ProfileField( label = "이름 *", - value = userInfo?.userName ?: "", + value = profile?.userName ?: userInfo?.userName ?: "", ) ProfileField( label = "이메일 *", - value = userInfo?.email ?: "", + value = profile?.userEmail ?: userInfo?.email ?: "", ) ProfileField( label = "휴대폰 번호", - value = supplierDetail?.manager?.phone ?: "", + value = profile?.userPhoneNumber ?: "", ) } } 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 index 3eb8ce6..bf757e1 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierProfileViewModel.kt @@ -3,14 +3,16 @@ 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.supplier.SupplierDetail +import com.autoever.everp.domain.model.profile.Profile import com.autoever.everp.domain.model.user.UserInfo -import com.autoever.everp.domain.repository.MmRepository +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 @@ -18,20 +20,27 @@ import javax.inject.Inject @HiltViewModel class SupplierProfileViewModel @Inject constructor( private val userRepository: UserRepository, - private val mmRepository: MmRepository, + private val profileRepository: ProfileRepository, private val sessionManager: SessionManager, ) : ViewModel() { private val _userInfo = MutableStateFlow(null) val userInfo: StateFlow = _userInfo.asStateFlow() - private val _supplierDetail = MutableStateFlow(null) - val supplierDetail: StateFlow = _supplierDetail.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() } @@ -42,15 +51,16 @@ class SupplierProfileViewModel @Inject constructor( // 사용자 정보 로드 userRepository.getUserInfo().onSuccess { userInfo -> _userInfo.value = userInfo - // 공급업체 정보 로드 - userInfo.userId.let { supplierId -> - mmRepository.getSupplierDetail(supplierId).onSuccess { supplierDetail -> - _supplierDetail.value = supplierDetail + // 프로필 정보 로드 + profileRepository.refreshProfile(userInfo.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") } - } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") } } catch (e: Exception) { - Timber.e(e, "사용자 정보 로드 실패") + Timber.e(e, "정보 로드 실패") } finally { _isLoading.value = false } From afe28a2d8790e11f320700fe6c275927982f405f Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 17:02:39 +0900 Subject: [PATCH 52/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=99=94=EB=A9=B4=20=EB=B0=8F?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/customer/CustomerProfileEditScreen.kt | 213 +++++++++++++++++- .../customer/CustomerProfileEditViewModel.kt | 85 +++++++ .../ui/customer/CustomerProfileScreen.kt | 26 ++- .../ui/customer/CustomerProfileViewModel.kt | 32 ++- 4 files changed, 331 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditViewModel.kt 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 index 7edff2f..9cda8a7 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileEditScreen.kt @@ -1,11 +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.navigation.NavHostController +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: NavHostController + 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 cab614c..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 @@ -38,7 +38,7 @@ fun CustomerProfileScreen( viewModel: CustomerProfileViewModel = hiltViewModel(), ) { val userInfo by viewModel.userInfo.collectAsState() - val customerDetail by viewModel.customerDetail.collectAsState() + val profile by viewModel.profile.collectAsState() val isLoading by viewModel.isLoading.collectAsState() Column( @@ -59,7 +59,9 @@ fun CustomerProfileScreen( fontWeight = FontWeight.Bold, ) androidx.compose.material3.TextButton( - onClick = { /* TODO: 편집 화면으로 이동 */ }, + onClick = { + navController.navigate(CustomerSubNavigationItem.ProfileEditItem.route) + }, ) { Text("편집") } @@ -116,19 +118,19 @@ fun CustomerProfileScreen( ) { ProfileField( label = "회사명 *", - value = customerDetail?.customerName ?: "", + value = profile?.companyName ?: "", ) ProfileField( - label = "회사 주소", - value = customerDetail?.fullAddress ?: "", + label = "사업자등록번호", + value = profile?.businessNumber ?: "", ) ProfileField( - label = "회사 전화번호", - value = customerDetail?.contactPhone ?: "", + label = "회사 주소", + value = profile?.fullAddress ?: "", ) ProfileField( - label = "사업자등록번호", - value = customerDetail?.businessNumber ?: "", + label = "회사 전화번호", + value = profile?.officePhone ?: "", ) } } @@ -154,15 +156,15 @@ fun CustomerProfileScreen( ) { ProfileField( label = "이름 *", - value = userInfo?.userName ?: "", + value = profile?.userName ?: userInfo?.userName ?: "", ) ProfileField( label = "이메일 *", - value = userInfo?.email ?: "", + value = profile?.userEmail ?: userInfo?.email ?: "", ) ProfileField( label = "휴대폰 번호", - value = customerDetail?.managerPhone ?: "", + value = profile?.userPhoneNumber ?: "", ) } } 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 index 7389463..5983e6d 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerProfileViewModel.kt @@ -3,14 +3,16 @@ 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.customer.CustomerDetail +import com.autoever.everp.domain.model.profile.Profile import com.autoever.everp.domain.model.user.UserInfo -import com.autoever.everp.domain.repository.SdRepository +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 @@ -19,19 +21,26 @@ import javax.inject.Inject class CustomerProfileViewModel @Inject constructor( private val sessionManager: SessionManager, private val userRepository: UserRepository, - private val sdRepository: SdRepository, + private val profileRepository: ProfileRepository, ) : ViewModel() { private val _userInfo = MutableStateFlow(null) val userInfo: StateFlow = _userInfo.asStateFlow() - private val _customerDetail = MutableStateFlow(null) - val customerDetail: StateFlow = _customerDetail.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() } @@ -42,15 +51,16 @@ class CustomerProfileViewModel @Inject constructor( // 사용자 정보 로드 userRepository.getUserInfo().onSuccess { userInfo -> _userInfo.value = userInfo - // 고객사 정보 로드 - userInfo.userId.let { customerId -> - sdRepository.getCustomerDetail(customerId).onSuccess { customerDetail -> - _customerDetail.value = customerDetail + // 프로필 정보 로드 + profileRepository.refreshProfile(userInfo.userType) + .onFailure { e -> + Timber.e(e, "프로필 정보 로드 실패") } - } + }.onFailure { e -> + Timber.e(e, "사용자 정보 로드 실패") } } catch (e: Exception) { - Timber.e(e, "사용자 정보 로드 실패") + Timber.e(e, "정보 로드 실패") } finally { _isLoading.value = false } From d8cecd34c604c95877b29b2be2bb97dd980607e5 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Mon, 10 Nov 2025 17:03:26 +0900 Subject: [PATCH 53/70] =?UTF-8?q?feat(ui):=20=EC=A0=84=ED=91=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/supplier/InvoiceDetailScreen.kt | 104 +++++++----------- 1 file changed, 39 insertions(+), 65 deletions(-) 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 index af99f71..2c0c89c 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt @@ -118,64 +118,49 @@ fun InvoiceDetailScreen( .fillMaxWidth() .padding(16.dp), ) { - // 좌우 2열 레이아웃 + + 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(), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // 왼쪽 열 - Column(modifier = Modifier.weight(1f)) { - DetailRow( - label = "전표번호", - value = detail.number, - ) - DetailRow( - label = "전표유형", - value = detail.type.displayName(), - ) - DetailRow( - label = "거래처", - value = detail.connectionName, - ) - DetailRow( - label = "적요", - value = detail.note.ifBlank { "-" }, - ) - } - - // 오른쪽 열 - Column(modifier = Modifier.weight(1f)) { - 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 { "-" }, - ) - } + Text( + text = "상태", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + StatusBadge( + text = detail.status.displayName(), + color = detail.status.toColor(), + ) } + DetailRow( + label = "메모", + value = detail.note.ifBlank { "-" }, + ) } } @@ -212,12 +197,6 @@ fun InvoiceDetailScreen( fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f), ) - Text( - text = "규격", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1.5f), - ) Text( text = "수량", style = MaterialTheme.typography.bodySmall, @@ -259,11 +238,6 @@ fun InvoiceDetailScreen( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f), ) - Text( - text = item.unitOfMaterialName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1.5f), - ) Text( text = "${item.quantity}", style = MaterialTheme.typography.bodyMedium, From c441c904a635b26f77fa8b468df8cf7da346b9d6 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 06:52:20 +0900 Subject: [PATCH 54/70] =?UTF-8?q?feat(dto):=20API=20Request&Response=20DTO?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote/http/service/AlarmApi.kt | 6 ++-- .../remote/http/service/DashboardApi.kt | 10 +++++-- .../datasource/remote/http/service/FcmApi.kt | 6 ++-- .../remote/mapper/NotificationMapper.kt | 2 +- .../everp/domain/model/dashboard/Dashboard.kt | 5 ++-- .../domain/model/invoice/InvoiceStatusEnum.kt | 30 +++++++++---------- 6 files changed, 32 insertions(+), 27 deletions(-) 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/DashboardApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/DashboardApi.kt index 0d9d4b9..f4e8732 100644 --- 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 @@ -2,13 +2,16 @@ 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 { @@ -33,7 +36,7 @@ data class DashboardWorkflowsResponseDto( @Serializable data class DashboardWorkflowTabDto( @SerialName("tabCode") - val tabCode: String, + val tabCode: DashboardTapEnum, @SerialName("items") val items: List, ) { @@ -50,8 +53,9 @@ data class DashboardWorkflowsResponseDto( @SerialName("statusCode") val statusCode: String, @SerialName("date") - @Serializable(with = LocalDateSerializer::class) - val date: LocalDate, +// @Serializable(with = LocalDateSerializer::class) +// val date: LocalDate, + val date: String, ) } } 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 c6e2c56..8b523bb 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 @@ -115,11 +115,11 @@ data class InvoiceListItemDto( @Serializable data class InvoiceConnectionDto( - @SerialName("companyId") + @SerialName("connectionId") val connectionId: String, - @SerialName("companyCode") + @SerialName("connectionNumber") val connectionNumber: String, - @SerialName("companyName") + @SerialName("connectionName") val connectionName: String, ) 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/domain/model/dashboard/Dashboard.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt index 95b3b5a..d63ac75 100644 --- 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 @@ -6,7 +6,7 @@ data class DashboardWorkflows( val tabs: List, ) { data class DashboardWorkflowTab( - val tabCode: String, + val tabCode: DashboardTapEnum, val items: List, ) @@ -16,7 +16,8 @@ data class DashboardWorkflows( val description: String, val createdBy: String, val status: String, - val createdAt: LocalDate, +// val createdAt: LocalDate, + val createdAt: String, ) } diff --git a/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt b/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt index 9d8a841..547a30c 100644 --- a/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt +++ b/app/src/main/java/com/autoever/everp/domain/model/invoice/InvoiceStatusEnum.kt @@ -4,9 +4,9 @@ import androidx.compose.ui.graphics.Color enum class InvoiceStatusEnum { UNKNOWN, // 알 수 없음, 기본값 - PENDING, // 미지급 - RESPONSE_PENDING, // 지급 대기 - COMPLETED, // 지급 완료 + UNPAID, // 미지급 + PENDING, // 지급 대기 + PAID, // 지급 완료 ; /** @@ -15,9 +15,9 @@ enum class InvoiceStatusEnum { fun toKorean(): String = when (this) { UNKNOWN -> "알 수 없음" - PENDING -> "미지급" - RESPONSE_PENDING -> "지급 대기" - COMPLETED -> "지급 완료" + UNPAID -> "미지급" + PENDING -> "지급 대기" + PAID -> "지급 완료" } /** @@ -36,9 +36,9 @@ enum class InvoiceStatusEnum { fun description(): String = when (this) { UNKNOWN -> "알 수 없는 상태" - PENDING -> "아직 지급되지 않은 청구서" - RESPONSE_PENDING -> "지급 처리 중인 청구서" - COMPLETED -> "지급이 완료된 청구서" + UNPAID -> "아직 지급되지 않은 청구서" + PENDING -> "지급 처리 중인 청구서" + PAID -> "지급이 완료된 청구서" } /** @@ -48,9 +48,9 @@ enum class InvoiceStatusEnum { fun toColor(): Color = when (this) { UNKNOWN -> Color(0xFF9E9E9E) // Grey - PENDING -> Color(0xFFF44336) // Red - RESPONSE_PENDING -> Color(0xFFFF9800) // Orange - COMPLETED -> Color(0xFF4CAF50) // Green + UNPAID -> Color(0xFFF44336) // Red + PENDING -> Color(0xFFFF9800) // Orange + PAID -> Color(0xFF4CAF50) // Green } /** @@ -61,17 +61,17 @@ enum class InvoiceStatusEnum { /** * 지급 완료 여부 */ - fun isPaid(): Boolean = this == COMPLETED + fun isPaid(): Boolean = this == PAID /** * 처리 가능 여부 (미지급 또는 대기 상태) */ - fun isProcessable(): Boolean = this == PENDING || this == RESPONSE_PENDING + fun isProcessable(): Boolean = this == UNPAID || this == PENDING /** * 알림이 필요한 상태인지 확인 */ - fun needsAlert(): Boolean = this == PENDING || this == RESPONSE_PENDING + fun needsAlert(): Boolean = this == UNPAID || this == PENDING companion object { /** From 060ab984b7d564cd0638cd3c4a0ea4d27669008a Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 06:55:11 +0900 Subject: [PATCH 55/70] =?UTF-8?q?feat(ui):=20=EA=B3=B5=EA=B8=89=EC=82=AC?= =?UTF-8?q?=20=ED=99=88=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=B5=9C=EA=B7=BC?= =?UTF-8?q?=20=ED=99=9C=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/common/RecentActivityCard.kt | 223 ++++++++++++++++++ .../everp/ui/supplier/SupplierHomeScreen.kt | 100 +------- .../ui/supplier/SupplierHomeViewModel.kt | 5 +- 3 files changed, 232 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt 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..3f0a30d --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt @@ -0,0 +1,223 @@ +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 + +/** + * 최근 활동 카드 컴포저블 + * + * 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( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = workflowId, + isAp = false, + ), + ) + } + // 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/supplier/SupplierHomeScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeScreen.kt index 318f225..6bf76f5 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 @@ -25,9 +25,12 @@ 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 @Composable @@ -112,14 +115,14 @@ fun SupplierHomeScreen( } else { recentActivities.forEach { activity -> item { - val category = categoryMap[activity.id] ?: "" + val category = categoryMap[activity.id] ?: DashboardTapEnum.UNKNOWN RecentActivityCard( - category = getCategoryDisplayName(category), + category = category.toKorean(), status = activity.status, title = activity.description, date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), onClick = { - navigateToDetail(navController, category, activity.id) + navigateToWorkflowDetail(navController, category, activity.id) }, ) } @@ -127,94 +130,3 @@ fun SupplierHomeScreen( } } } - -@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/supplier/SupplierHomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt index d9d173f..a7fdf0a 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -2,6 +2,7 @@ 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.repository.DashboardRepository import com.autoever.everp.domain.repository.UserRepository @@ -23,8 +24,8 @@ class SupplierHomeViewModel @Inject constructor( val recentActivities: StateFlow> get() = _recentActivities.asStateFlow() - private val _categoryMap = MutableStateFlow>(emptyMap()) - val categoryMap: StateFlow> + private val _categoryMap = MutableStateFlow>(emptyMap()) + val categoryMap: StateFlow> get() = _categoryMap.asStateFlow() private val _isLoading = MutableStateFlow(false) From 9ab1ec5800f7eee2f89cc9841d758fc2da6fb226 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 06:56:24 +0900 Subject: [PATCH 56/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC?= =?UTF-8?q?=20=ED=99=88=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoever/everp/ui/customer/CustomerApp.kt | 6 + .../everp/ui/customer/CustomerHomeScreen.kt | 328 ++++++++---------- .../ui/customer/CustomerHomeViewModel.kt | 33 +- .../ui/customer/CustomerNavigationItem.kt | 2 + 4 files changed, 176 insertions(+), 193 deletions(-) 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 8fcf3f0..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 @@ -106,6 +106,12 @@ fun CustomerApp( ) { CustomerProfileEditScreen(navController = navController) } + + composable( + route = CustomerSubNavigationItem.NotificationItem.route, + ) { + NotificationScreen(navController = navController) + } } } } 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 185e8d5..b8daeff 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,35 +1,43 @@ package com.autoever.everp.ui.customer import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box 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.material3.Card -import androidx.compose.material3.CardDefaults +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.components.StatusBadge +import com.autoever.everp.ui.common.navigateToWorkflowDetail import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomerHomeScreen( navController: NavController, @@ -38,203 +46,141 @@ fun CustomerHomeScreen( val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val hasUnreadNotifications by viewModel.hasUnreadNotifications.collectAsStateWithLifecycle() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - item { - Text( - text = "차량 외장재 관리", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + Scaffold( + topBar = { + TopAppBar( + title = { Text("차량 외장재 관리") }, + actions = { + Box( + modifier = Modifier + .padding(end = 8.dp) + .size(48.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) + .padding(top = 8.dp, end = 8.dp), + shape = CircleShape, + color = Color.Red, + ) { + // 빨간색 점 + } + } + } + }, ) - } - - item { - Text( - text = "안녕하세요!", - style = MaterialTheme.typography.titleLarge, - ) - Text( - text = "오늘도 효율적인 업무 관리를 시작해보세요.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + }, + ) { 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 { + 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 { + 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 = "로딩 중...") + Text( + text = "최근 활동", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) } - } else { - recentActivities.forEach { activity -> + + if (isLoading) { item { - val category = categoryMap[activity.id] ?: "" - RecentActivityCard( - category = getCategoryDisplayName(category), - status = activity.status, - title = activity.description, - date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { - navigateToDetail(navController, category, activity.id) - }, - ) + Text(text = "로딩 중...") } - } - } - } -} - -@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, - ) + } 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 = { + navigateToWorkflowDetail(navController, category, activity.id) + }, + ) + } } } - 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()) { - "QUOTATION", "견적" -> { - navController.navigate( - CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = workflowId) - ) - } - "ORDER", "주문", "SALES_ORDER" -> { - navController.navigate( - CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(workflowId) - ) - } - "INVOICE", "전표", "AP_INVOICE", "AR_INVOICE" -> { - navController.navigate( - CustomerSubNavigationItem.InvoiceDetailItem.createRoute( - invoiceId = workflowId, - isAp = category.contains("AP", ignoreCase = true), - ), - ) } - // Customer 화면에서는 발주 상세로 이동하지 않음 } } 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 index 57b4653..f5d4065 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -2,13 +2,18 @@ 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 @@ -17,21 +22,27 @@ import javax.inject.Inject 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> + 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() { @@ -71,6 +82,24 @@ class CustomerHomeViewModel @Inject constructor( 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(NotificationStatusEnum.UNREAD) + .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 72a6119..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 @@ -75,4 +75,6 @@ sealed class CustomerSubNavigationItem( } object ProfileEditItem : CustomerSubNavigationItem("customer_profile_edit", "프로필 수정") + + object NotificationItem : CustomerSubNavigationItem("customer_notification", "알림 목록") } From fc63a90a5f3d2e9a7a230422ae1cc4edd61b0ad9 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 06:56:41 +0900 Subject: [PATCH 57/70] =?UTF-8?q?feat(ui):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/customer/NotificationScreen.kt | 236 ++++++++++++++++++ .../ui/customer/NotificationViewModel.kt | 106 ++++++++ 2 files changed, 342 insertions(+) create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/customer/NotificationViewModel.kt 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..b7c8de8 --- /dev/null +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt @@ -0,0 +1,236 @@ +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 java.time.format.DateTimeFormatter + +@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 = notification.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), + 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.QUOTATION -> { + navController.navigate( + CustomerSubNavigationItem.QuotationDetailItem.createRoute(notification.linkId), + ) + } + NotificationLinkEnum.SALES_ORDER -> { + navController.navigate( + CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(notification.linkId), + ) + } + NotificationLinkEnum.PURCHASE_INVOICE, + NotificationLinkEnum.SALES_INVOICE -> { + val isAp = notification.linkType == NotificationLinkEnum.PURCHASE_INVOICE + navController.navigate( + CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + invoiceId = notification.linkId, + isAp = isAp, + ), + ) + } + else -> { + // 기타 알림은 화면 이동 없음 + } + } +} 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() + } +} + From c9ab05cd3203dd067a129bc9ad16f9a9637d9b95 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 10:13:04 +0900 Subject: [PATCH 58/70] =?UTF-8?q?feat(data):=20=EC=A0=84=ED=91=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20data-domain=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FcmRemoteDataSource - FcmRepository --- .../datasource/remote/FcmRemoteDataSource.kt | 6 ++--- .../http/impl/FcmHttpRemoteDataSourceImpl.kt | 26 +++++++++---------- .../datasource/remote/http/service/FcmApi.kt | 8 +++--- .../data/repository/FcmRepositoryImpl.kt | 14 +++++----- .../everp/domain/repository/FcmRepository.kt | 2 +- 5 files changed, 28 insertions(+), 28 deletions(-) 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/http/impl/FcmHttpRemoteDataSourceImpl.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/impl/FcmHttpRemoteDataSourceImpl.kt index cbe2374..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 @@ -165,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/service/FcmApi.kt b/app/src/main/java/com/autoever/everp/data/datasource/remote/http/service/FcmApi.kt index 8b523bb..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" 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 68631ab..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 @@ -135,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/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 } From 9394d54033a876e3d4bac41d927b8fb98f344e23 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 10:13:52 +0900 Subject: [PATCH 59/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC,?= =?UTF-8?q?=20=EA=B3=B5=EA=B8=89=EC=82=AC=20=EC=A0=84=ED=91=9C=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/customer/InvoiceDetailScreen.kt | 53 ++++++++++++++++++ .../ui/customer/InvoiceDetailViewModel.kt | 17 ++++++ .../everp/ui/supplier/InvoiceDetailScreen.kt | 54 +++++++++++++++++++ .../ui/supplier/InvoiceDetailViewModel.kt | 20 ++++++- 4 files changed, 143 insertions(+), 1 deletion(-) 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 index c1fb1bb..0233993 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailScreen.kt @@ -13,6 +13,7 @@ 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 @@ -22,6 +23,8 @@ 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 @@ -47,6 +50,7 @@ fun InvoiceDetailScreen( ) { 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) @@ -255,12 +259,61 @@ fun InvoiceDetailScreen( } } } + + // 납부 확인 요청 버튼 (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("확인") + } + }, + ) + } } } 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 index 90d2a35..3d12474 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/InvoiceDetailViewModel.kt @@ -23,6 +23,9 @@ class InvoiceDetailViewModel @Inject constructor( 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 @@ -63,5 +66,19 @@ class InvoiceDetailViewModel @Inject constructor( 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/supplier/InvoiceDetailScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt index 2c0c89c..20d40de 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailScreen.kt @@ -13,6 +13,7 @@ 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 @@ -24,6 +25,8 @@ 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 @@ -48,6 +51,8 @@ fun InvoiceDetailScreen( ) { 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() @@ -285,12 +290,61 @@ fun InvoiceDetailScreen( } } } + + // 납부 확인 버튼 (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("확인") + } + }, + ) + } } } 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 index fa48122..f982e80 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/InvoiceDetailViewModel.kt @@ -24,7 +24,7 @@ class InvoiceDetailViewModel @Inject constructor( SupplierSubNavigationItem.InvoiceDetailItem.ARG_ID, ) ?: "" - private val isAp: Boolean = savedStateHandle.get( + val isAp: Boolean = savedStateHandle.get( SupplierSubNavigationItem.InvoiceDetailItem.ARG_IS_AP, ) ?: true @@ -34,6 +34,9 @@ class InvoiceDetailViewModel @Inject constructor( 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() @@ -62,6 +65,7 @@ class InvoiceDetailViewModel @Inject constructor( _uiState.value = UiResult.Error(e as Exception) } } else { + // TODO 지금은 전표 없음으로 변경 fcmRepository.refreshArInvoiceDetail(invoiceId) .onSuccess { fcmRepository.getArInvoiceDetail(invoiceId) @@ -81,5 +85,19 @@ class InvoiceDetailViewModel @Inject constructor( } } } + + fun completeReceivable() { + viewModelScope.launch { + _completeResult.value = fcmRepository.completeReceivable(invoiceId) + .onSuccess { + // 성공 시 상세 정보 다시 로드 + loadInvoiceDetail() + } + } + } + + fun clearCompleteResult() { + _completeResult.value = null + } } From 1d033f6429543497958fe99fae3c264d71958a79 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 10:57:03 +0900 Subject: [PATCH 60/70] =?UTF-8?q?feat(domain):=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20Model=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../remote/mapper/DashboardMapper.kt | 28 +++++++++---------- .../everp/domain/model/dashboard/Dashboard.kt | 6 ---- .../model/dashboard/DashboardTapEnum.kt | 8 ++++++ .../ui/customer/CustomerHomeViewModel.kt | 17 ++++------- .../ui/supplier/SupplierHomeViewModel.kt | 16 ++++------- 5 files changed, 33 insertions(+), 42 deletions(-) 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 index dc69d9b..13a4736 100644 --- 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 @@ -6,21 +6,19 @@ import com.autoever.everp.domain.model.dashboard.DashboardWorkflows object DashboardMapper { fun toDomain(dto: DashboardWorkflowsResponseDto): DashboardWorkflows = DashboardWorkflows( - tabs = dto.tabs.map { tab -> - DashboardWorkflows.DashboardWorkflowTab( - tabCode = tab.tabCode, - items = tab.items.map { item -> - DashboardWorkflows.DashboardWorkflowItem( - id = item.itemId, - number = item.itemNumber, - description = item.itemTitle, - createdBy = item.name, - status = item.statusCode, - createdAt = item.date, - ) - }, - ) - }, + 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/domain/model/dashboard/Dashboard.kt b/app/src/main/java/com/autoever/everp/domain/model/dashboard/Dashboard.kt index d63ac75..de97391 100644 --- 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 @@ -1,16 +1,10 @@ package com.autoever.everp.domain.model.dashboard -import java.time.LocalDate - data class DashboardWorkflows( val tabs: List, ) { data class DashboardWorkflowTab( val tabCode: DashboardTapEnum, - val items: List, - ) - - data class DashboardWorkflowItem( val id: String, val number: String, val description: 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 index e906c50..667ee17 100644 --- 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 @@ -155,6 +155,14 @@ enum class DashboardTapEnum { else -> null } + fun isCustomerRelated(): Boolean { + return this == AR || this == SO || this == QT + } + + fun isSupplierRelated(): Boolean { + return this == AP || this == PO + } + companion object { /** * 문자열을 DashboardTapEnum으로 변환 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 index f5d4065..8a23c1b 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -25,8 +25,8 @@ class CustomerHomeViewModel @Inject constructor( private val alarmRepository: AlarmRepository, ) : ViewModel() { - private val _recentActivities = MutableStateFlow>(emptyList()) - val recentActivities: StateFlow> + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> get() = _recentActivities.asStateFlow() private val _categoryMap = MutableStateFlow>(emptyMap()) @@ -53,16 +53,11 @@ class CustomerHomeViewModel @Inject constructor( val role = userInfo.userRole dashboardRepository.refreshWorkflows(role).onSuccess { dashboardRepository.getWorkflows(role).onSuccess { workflows -> - // 모든 tabs의 items를 하나의 리스트로 합치고 날짜순으로 정렬 - val allItems = workflows.tabs.flatMap { tab -> - tab.items.map { item -> - item to tab.tabCode - } - }.sortedByDescending { it.first.createdAt } + // tabs를 날짜순으로 정렬 + val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } .take(10) // 최근 10개만 - - _recentActivities.value = allItems.map { it.first } - _categoryMap.value = allItems.associate { it.first.id to it.second } + _recentActivities.value = sortedTabs + _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } }.onFailure { e -> Timber.e(e, "워크플로우 조회 실패") } 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 index a7fdf0a..8357c58 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -20,8 +20,8 @@ class SupplierHomeViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { - private val _recentActivities = MutableStateFlow>(emptyList()) - val recentActivities: StateFlow> + private val _recentActivities = MutableStateFlow>(emptyList()) + val recentActivities: StateFlow> get() = _recentActivities.asStateFlow() private val _categoryMap = MutableStateFlow>(emptyMap()) @@ -43,16 +43,12 @@ class SupplierHomeViewModel @Inject constructor( val role = userInfo.userRole dashboardRepository.refreshWorkflows(role).onSuccess { dashboardRepository.getWorkflows(role).onSuccess { workflows -> - // 모든 tabs의 items를 하나의 리스트로 합치고 날짜순으로 정렬 - val allItems = workflows.tabs.flatMap { tab -> - tab.items.map { item -> - item to tab.tabCode - } - }.sortedByDescending { it.first.createdAt } + // tabs를 날짜순으로 정렬 + val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } .take(10) // 최근 10개만 - _recentActivities.value = allItems.map { it.first } - _categoryMap.value = allItems.associate { it.first.id to it.second } + _recentActivities.value = sortedTabs + _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } }.onFailure { e -> Timber.e(e, "워크플로우 조회 실패") } From f1289ff3aa4c757ccd0d31b813480bbbd2335624 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 10:59:51 +0900 Subject: [PATCH 61/70] =?UTF-8?q?feat(ui):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=20=ED=95=AD=EB=AA=A9=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/NotificationLinkEnum.kt | 15 +++++++++++++ .../everp/ui/common/RecentActivityCard.kt | 21 +++++++++++++------ .../everp/ui/customer/CustomerHomeScreen.kt | 4 +++- .../everp/ui/supplier/SupplierHomeScreen.kt | 9 ++++---- 4 files changed, 38 insertions(+), 11 deletions(-) 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/ui/common/RecentActivityCard.kt b/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt index 3f0a30d..4b92478 100644 --- a/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt +++ b/app/src/main/java/com/autoever/everp/ui/common/RecentActivityCard.kt @@ -20,6 +20,7 @@ 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 /** * 최근 활동 카드 컴포저블 @@ -92,19 +93,19 @@ fun navigateToWorkflowDetail( workflowId: String, ) { when (category) { - DashboardTapEnum.QT -> { + DashboardTapEnum.QT -> { // 견적 navController.navigate( CustomerSubNavigationItem.QuotationDetailItem.createRoute(quotationId = workflowId) ) } - DashboardTapEnum.SO -> { + DashboardTapEnum.SO -> { // 주문 navController.navigate( CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(workflowId) ) } - DashboardTapEnum.AP -> { + DashboardTapEnum.AP -> { // 매입 navController.navigate( CustomerSubNavigationItem.InvoiceDetailItem.createRoute( invoiceId = workflowId, @@ -113,12 +114,20 @@ fun navigateToWorkflowDetail( ) } - DashboardTapEnum.AR -> { + DashboardTapEnum.AR -> { // 매출 navController.navigate( - CustomerSubNavigationItem.InvoiceDetailItem.createRoute( + SupplierSubNavigationItem.InvoiceDetailItem.createRoute( invoiceId = workflowId, isAp = false, - ), + ) + ) + } + + DashboardTapEnum.PO -> { // 발주 + navController.navigate( + SupplierSubNavigationItem.PurchaseOrderDetailItem.createRoute( + workflowId + ) ) } // Customer 화면에서는 발주, 구매 등 상세로 이동하지 않음 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 b8daeff..b00b1fb 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 @@ -175,7 +175,9 @@ fun CustomerHomeScreen( title = activity.description, date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), onClick = { - navigateToWorkflowDetail(navController, category, activity.id) + if (activity.tabCode.isCustomerRelated()) { + navigateToWorkflowDetail(navController, category, activity.id) + } }, ) } 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 6bf76f5..cd6b91e 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 @@ -121,10 +121,11 @@ fun SupplierHomeScreen( status = activity.status, title = activity.description, date = activity.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - onClick = { - navigateToWorkflowDetail(navController, category, activity.id) - }, - ) + onClick = { + if (activity.tabCode.isSupplierRelated()) { + navigateToWorkflowDetail(navController, category, activity.id) + } + }, } } } From b9f99f3339039bf708e98df99416383a451bebaf Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 11:02:09 +0900 Subject: [PATCH 62/70] =?UTF-8?q?feat(ui):=20=EA=B3=A0=EA=B0=9D=EC=82=AC&?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EC=82=AC=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoever/everp/ui/supplier/SupplierApp.kt | 5 + .../everp/ui/supplier/SupplierHomeScreen.kt | 187 +++++++++++------- .../ui/supplier/SupplierHomeViewModel.kt | 28 +++ .../ui/supplier/SupplierNavigationItem.kt | 2 + 4 files changed, 153 insertions(+), 69 deletions(-) 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 fc52773..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 @@ -72,6 +72,11 @@ fun SupplierApp( ) { SupplierProfileEditScreen(navController = navController) } + composable( + route = SupplierSubNavigationItem.NotificationItem.route, + ) { + NotificationScreen(navController = navController) + } } } } 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 cd6b91e..8d9acc9 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,25 +1,36 @@ 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 @@ -33,6 +44,7 @@ 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( navController: NavController, @@ -41,91 +53,128 @@ fun SupplierHomeScreen( val recentActivities by viewModel.recentActivities.collectAsStateWithLifecycle() val categoryMap by viewModel.categoryMap.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val hasUnreadNotifications by viewModel.hasUnreadNotifications.collectAsStateWithLifecycle() - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - item { - Text( - text = "차량 외장재 관리", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + Scaffold( + topBar = { + TopAppBar( + title = { Text("차량 외장재 관리") }, + actions = { + Box( + modifier = Modifier + .padding(end = 8.dp) + .size(48.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) + .padding(top = 8.dp, end = 8.dp), + 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.titleLarge, + ) + Text( + text = "오늘도 효율적인 업무 관리를 시작해보세요.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - item { - Text( - text = "빠른 작업", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - } + 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 { + 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 = "로딩 중...") + Text( + text = "최근 활동", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) } - } else { - recentActivities.forEach { activity -> + + if (isLoading) { 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")), + 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 index 8357c58..6e8de94 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -4,12 +4,16 @@ 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 @@ -18,6 +22,7 @@ import javax.inject.Inject class SupplierHomeViewModel @Inject constructor( private val dashboardRepository: DashboardRepository, private val userRepository: UserRepository, + private val alarmRepository: AlarmRepository, ) : ViewModel() { private val _recentActivities = MutableStateFlow>(emptyList()) @@ -31,8 +36,13 @@ class SupplierHomeViewModel @Inject constructor( 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() { @@ -68,5 +78,23 @@ class SupplierHomeViewModel @Inject constructor( 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(NotificationStatusEnum.UNREAD) + .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 d2b7f5a..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 @@ -59,4 +59,6 @@ sealed class SupplierSubNavigationItem( } object ProfileEditItem : SupplierSubNavigationItem("supplier_profile_edit", "프로필 수정") + + object NotificationItem : SupplierSubNavigationItem("supplier_notification", "알림 목록") } From f76a490868d42aa1d2f047e5eb134c199583752b Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 11:02:46 +0900 Subject: [PATCH 63/70] =?UTF-8?q?feat(ui):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=81=20=EC=95=8C=EB=A6=BC=20=ED=95=AD=EB=AA=A9=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../everp/ui/customer/NotificationScreen.kt | 115 +++++--- .../everp/ui/supplier/NotificationScreen.kt | 256 ++++++++++++++++++ .../ui/supplier/NotificationViewModel.kt | 105 +++++++ 3 files changed, 439 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/NotificationScreen.kt create mode 100644 app/src/main/java/com/autoever/everp/ui/supplier/NotificationViewModel.kt 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 index b7c8de8..b531862 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt @@ -35,7 +35,10 @@ 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.format.DateTimeFormatter +import com.autoever.everp.ui.supplier.SupplierSubNavigationItem +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,6 +77,7 @@ fun NotificationScreen( CircularProgressIndicator() } } + error != null -> { Box( modifier = Modifier @@ -92,6 +96,7 @@ fun NotificationScreen( } } } + notifications.content.isEmpty() -> { Box( modifier = Modifier @@ -106,6 +111,7 @@ fun NotificationScreen( ) } } + else -> { LazyColumn( modifier = Modifier @@ -120,7 +126,9 @@ fun NotificationScreen( onClick = { // 알림 클릭 시 읽음 처리 및 상세 화면 이동 viewModel.markAsRead(notification.id) - navigateToDetail(navController, notification) + if (notification.linkType.isCustomerRelated()) { + navigateToDetail(navController, notification) + } }, ) } @@ -152,34 +160,6 @@ private fun NotificationItem( .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() @@ -187,15 +167,29 @@ private fun NotificationItem( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = notification.createdAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = notification.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.Bold, ) + StatusBadge( text = notification.source.toKorean(), - color = MaterialTheme.colorScheme.secondary, + 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, + ) } } } @@ -214,23 +208,70 @@ private fun navigateToDetail( CustomerSubNavigationItem.QuotationDetailItem.createRoute(notification.linkId), ) } + NotificationLinkEnum.SALES_ORDER -> { navController.navigate( CustomerSubNavigationItem.SalesOrderDetailItem.createRoute(notification.linkId), ) } - NotificationLinkEnum.PURCHASE_INVOICE, + NotificationLinkEnum.SALES_INVOICE -> { - val isAp = notification.linkType == NotificationLinkEnum.PURCHASE_INVOICE navController.navigate( CustomerSubNavigationItem.InvoiceDetailItem.createRoute( invoiceId = notification.linkId, - isAp = isAp, + 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/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() + } +} + From b10cfddecd3a3232444cb5d12475416fb7158410 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 11:13:15 +0900 Subject: [PATCH 64/70] =?UTF-8?q?feat(ui):=20=EC=A0=84=ED=91=9C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=84=A0=ED=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/customer/CustomerVoucherScreen.kt | 58 +++++++++---------- .../ui/customer/CustomerVoucherViewModel.kt | 36 ++++++------ .../ui/supplier/SupplierVoucherScreen.kt | 58 +++++++++---------- .../ui/supplier/SupplierVoucherViewModel.kt | 34 +++++------ 4 files changed, 93 insertions(+), 93 deletions(-) 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 3e2819c..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 @@ -31,7 +31,7 @@ fun CustomerVoucherScreen( ) { val invoiceList by viewModel.invoiceList.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() - val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() +// val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() val isLoading by viewModel.isLoading.collectAsState() Column( @@ -53,29 +53,29 @@ fun CustomerVoucherScreen( 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), - ) - } +// // 전체 선택 체크박스 +// 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) { @@ -92,11 +92,11 @@ fun CustomerVoucherScreen( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = selectedInvoiceIds.contains(invoice.id), - onCheckedChange = { viewModel.toggleInvoiceSelection(invoice.id) }, - modifier = Modifier.padding(start = 8.dp), - ) +// 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, 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 index 6b02874..15d2719 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerVoucherViewModel.kt @@ -29,9 +29,9 @@ class CustomerVoucherViewModel @Inject constructor( val searchQuery: StateFlow get() = _searchQuery.asStateFlow() - private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) - val selectedInvoiceIds: StateFlow> - get() = _selectedInvoiceIds.asStateFlow() +// private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) +// val selectedInvoiceIds: StateFlow> +// get() = _selectedInvoiceIds.asStateFlow() private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow @@ -81,20 +81,20 @@ class CustomerVoucherViewModel @Inject constructor( 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() - } +// 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/supplier/SupplierVoucherScreen.kt b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherScreen.kt index e4be7eb..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 @@ -31,7 +31,7 @@ fun SupplierVoucherScreen( ) { val invoiceList by viewModel.invoiceList.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() - val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() +// val selectedInvoiceIds by viewModel.selectedInvoiceIds.collectAsState() val isLoading by viewModel.isLoading.collectAsState() Column( @@ -53,29 +53,29 @@ fun SupplierVoucherScreen( 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), - ) - } +// // 전체 선택 체크박스 +// 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) { @@ -92,11 +92,11 @@ fun SupplierVoucherScreen( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox( - checked = selectedInvoiceIds.contains(invoice.id), - onCheckedChange = { viewModel.toggleInvoiceSelection(invoice.id) }, - modifier = Modifier.padding(start = 8.dp), - ) +// 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, 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 index 6286d3f..692dc26 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierVoucherViewModel.kt @@ -27,8 +27,8 @@ class SupplierVoucherViewModel @Inject constructor( private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() - private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) - val selectedInvoiceIds: StateFlow> = _selectedInvoiceIds.asStateFlow() +// private val _selectedInvoiceIds = MutableStateFlow>(emptySet()) +// val selectedInvoiceIds: StateFlow> = _selectedInvoiceIds.asStateFlow() private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -77,20 +77,20 @@ class SupplierVoucherViewModel @Inject constructor( 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() - } +// 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() +// } } From b1db31c9668532e75281b877f7ffa40ef381d741 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 11:13:37 +0900 Subject: [PATCH 65/70] =?UTF-8?q?feat(ui):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=20=ED=91=9C=EC=8B=9C=2010=20->=205=20?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/ui/customer/CustomerHomeViewModel.kt | 2 +- .../com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 8a23c1b..22cbd28 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -55,7 +55,7 @@ class CustomerHomeViewModel @Inject constructor( dashboardRepository.getWorkflows(role).onSuccess { workflows -> // tabs를 날짜순으로 정렬 val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } - .take(10) // 최근 10개만 + .take(5) // 최근 5개만 _recentActivities.value = sortedTabs _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } }.onFailure { e -> 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 index 6e8de94..4c5ca79 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -55,7 +55,7 @@ class SupplierHomeViewModel @Inject constructor( dashboardRepository.getWorkflows(role).onSuccess { workflows -> // tabs를 날짜순으로 정렬 val sortedTabs = workflows.tabs.sortedByDescending { it.createdAt } - .take(10) // 최근 10개만 + .take(5) // 최근 5개만 _recentActivities.value = sortedTabs _categoryMap.value = sortedTabs.associate { it.id to it.tabCode } From baab3cdcc59220f90453af409258db35f3efcf20 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 11:13:56 +0900 Subject: [PATCH 66/70] =?UTF-8?q?feat(ui):=20=EC=9D=BD=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=95=8C=EB=A6=BC=EB=A7=8C=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=8B=9C=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/autoever/everp/ui/customer/NotificationScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b531862..0495ece 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/NotificationScreen.kt @@ -125,7 +125,7 @@ fun NotificationScreen( notification = notification, onClick = { // 알림 클릭 시 읽음 처리 및 상세 화면 이동 - viewModel.markAsRead(notification.id) + if (!notification.isRead) viewModel.markAsRead(notification.id) if (notification.linkType.isCustomerRelated()) { navigateToDetail(navController, notification) } From 2b8b7df834d313c819572c2a12097e1070380c8e Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 12:00:52 +0900 Subject: [PATCH 67/70] =?UTF-8?q?feat(data):=20=EC=9D=BD=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=95=8C=EB=A6=BC=20=EA=B0=AF=EC=88=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/AlarmRepositoryImpl.kt | 36 +++++++++++++++---- .../domain/repository/AlarmRepository.kt | 10 +++--- .../ui/customer/CustomerHomeViewModel.kt | 2 +- .../ui/supplier/SupplierHomeViewModel.kt | 2 +- 4 files changed, 35 insertions(+), 15 deletions(-) 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 fee055c..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 @@ -10,6 +10,7 @@ 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 @@ -55,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 = withContext(Dispatchers.Default) { - alarmRemoteDataSource.getNotificationCount(status = status) - .map { NotificationMapper.toDomain(it) } + // 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/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/ui/customer/CustomerHomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt index 22cbd28..ffa803c 100644 --- a/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/customer/CustomerHomeViewModel.kt @@ -90,7 +90,7 @@ class CustomerHomeViewModel @Inject constructor( private fun refreshNotificationCount() { viewModelScope.launch { - alarmRepository.refreshNotificationCount(NotificationStatusEnum.UNREAD) + alarmRepository.refreshNotificationCount() .onFailure { e -> Timber.e(e, "알림 개수 갱신 실패") } 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 index 4c5ca79..5273e90 100644 --- a/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt +++ b/app/src/main/java/com/autoever/everp/ui/supplier/SupplierHomeViewModel.kt @@ -91,7 +91,7 @@ class SupplierHomeViewModel @Inject constructor( private fun refreshNotificationCount() { viewModelScope.launch { - alarmRepository.refreshNotificationCount(NotificationStatusEnum.UNREAD) + alarmRepository.refreshNotificationCount() .onFailure { e -> Timber.e(e, "알림 개수 갱신 실패") } From dd3707e680de87e0a5439db558aa4e5a157818aa Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 12:01:32 +0900 Subject: [PATCH 68/70] =?UTF-8?q?feat(ui):=20=EC=9D=BD=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=80=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=ED=95=98=EB=A9=B4=20=EC=95=8C=EB=A6=BC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EC=9C=84=EC=97=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/autoever/everp/ui/customer/CustomerHomeScreen.kt | 7 ++++--- .../com/autoever/everp/ui/supplier/SupplierHomeScreen.kt | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) 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 b00b1fb..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 @@ -35,6 +35,7 @@ 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) @@ -56,7 +57,8 @@ fun CustomerHomeScreen( Box( modifier = Modifier .padding(end = 8.dp) - .size(48.dp), + .size(48.dp) + .padding(top = 16.dp, end = 16.dp), contentAlignment = Alignment.Center, ) { IconButton( @@ -75,8 +77,7 @@ fun CustomerHomeScreen( Surface( modifier = Modifier .size(8.dp) - .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 8.dp), + .align(Alignment.TopEnd), shape = CircleShape, color = Color.Red, ) { 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 8d9acc9..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 @@ -63,7 +63,8 @@ fun SupplierHomeScreen( Box( modifier = Modifier .padding(end = 8.dp) - .size(48.dp), + .size(48.dp) + .padding(top = 16.dp, end = 16.dp), contentAlignment = Alignment.Center, ) { IconButton( @@ -82,8 +83,7 @@ fun SupplierHomeScreen( Surface( modifier = Modifier .size(8.dp) - .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 8.dp), + .align(Alignment.TopEnd), shape = CircleShape, color = Color.Red, ) { From ced6ee5df452f9dff1fe8cb8d86997db22c87143 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 16:08:36 +0900 Subject: [PATCH 69/70] =?UTF-8?q?feat(data):=20FcmToken=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=20=EB=B0=98=ED=99=98=EA=B0=92=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AlarmHttpRemoteDataSourceImpl.kt | 1 + .../remote/http/service/AlarmTokenApi.kt | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) 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 cfc2067..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 @@ -143,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/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, +) + From c3ac4fd35d6fbdb29f14cc7995a599a4505d2b09 Mon Sep 17 00:00:00 2001 From: parkjiwon Date: Tue, 11 Nov 2025 16:09:34 +0900 Subject: [PATCH 70/70] =?UTF-8?q?feat(fcm):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,?= =?UTF-8?q?=20FCM=20=ED=86=A0=ED=81=B0=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=97=90=20=ED=86=A0=ED=81=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/fcm/MyFirebaseMessagingService.kt | 59 ++++++++++++++++++- .../autoever/everp/ui/home/HomeViewModel.kt | 34 +++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) 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/home/HomeViewModel.kt b/app/src/main/java/com/autoever/everp/ui/home/HomeViewModel.kt index c47a592..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 @@ -7,6 +7,9 @@ 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.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 @@ -18,6 +21,9 @@ import javax.inject.Inject 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 @@ -39,6 +45,8 @@ class HomeViewModel @Inject constructor( "role=${info.userRole ?: "null"}, " + "userType=${info.userType ?: "null"}" ) + // 사용자 정보 로드 성공 후 FCM 토큰 등록 + registerFcmToken() } catch (e: UnauthorizedException) { Timber.tag(TAG).w("[WARN] 인증 만료로 로그아웃 처리") sessionManager.signOut() @@ -51,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" }