commit 6edd98a268b2923f7055938e2ccf884f513aaa77 Author: Wira Irawan Date: Thu May 21 23:36:13 2026 +0700 Initial mobile app implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f46c0f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.kotlin/ +.idea/ +build/ +app/build/ +local.properties +*.iml +.DS_Store diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md new file mode 100644 index 0000000..0974297 --- /dev/null +++ b/CODEX_HANDOFF.md @@ -0,0 +1,98 @@ +# Codex Handoff + +## Project +- Native Android app +- Package: `id.abelbirdnest.mobile` +- App name: `Abelbirdnest Stock` +- Stack: Kotlin + Jetpack Compose +- Blueprint source of truth: [mobile-api-blueprint.md](./mobile-api-blueprint.md) + +## API / Environment +- Production base URL is used from Postman env. +- Active API host used during testing: `https://abelbirdnest.id/api/v1` + +## Test Accounts +- `OWNER` -> `abel@zappcare.id` / `abel1234` +- `PURCHASING` -> `tini@zappcare.id` / `tini1234` +- `SALES` -> `budi@zappcare.id` / `budi1234` +- `QC` -> `edi@zappcare.id` / `edi12345` +- `WAREHOUSE` -> `jhon@zappcare.id` / `jhon1234` + +## Implemented Modules +- `dashboard` +- `lots` +- `washing` +- `stock_adjustments` +- `purchases` +- `purchase_analyses` +- `purchase_realizations` +- `fund_requests` +- `sales_regular` +- `sales_jit` +- `consignments` +- `lot_transformations` + +## Hidden / Removed From Mobile Navigation +- `receipts` + - intentionally hidden because newer server/mobile flow no longer uses it for active role navigation + +## Important Behavior +- App is server-driven from `mobile/bootstrap`, but module ordering and UI prioritization were aligned to the blueprint where needed. +- `PURCHASING` login issue was fixed by not forcing `/mobile/lots` fetch for roles that do not have `lots`. +- Quick actions now only show modules that are not already present in the bottom bar. +- For `OWNER`, quick actions were reduced to match actual mobile need. Extra actions like `dana`, `sales`, `jit`, `titip jual`, and `washing` were removed from quick actions. + +## Recent UI Changes + +### Login +- Brand text changed to `Abelbirdnest` +- Centered vertically +- `Lupa Kata Sandi?` restored +- Removed extra elements below the main login CTA except forgot password + +### Lots +- Lot detail top gap was reduced by: + - removing extra spacer + - disabling nested scaffold content insets in detail +- Lot detail footer actions were removed entirely: + - `Print Ulang` + - `Pindah Lokasi` + - `Ubah Status` + - reason: no real mobile action flow yet, avoid dead buttons + +### Scan Lot +- Scanner layout was reworked: + - `Tutup` moved outside the camera panel + - scanner panel wrapped as its own card/section + - manual input visually separated from camera section + - scanner corner markers fixed so their directions are correct + +### Washing +- `Lot` picker uses search / scan workflow +- `Tempat Cuci` changed to simple dropdown +- card action button `Selesaikan` forced to one line by reducing font and spacing + +## Known Gaps / Things Not Yet Wired +- Some modules are present as minimum mobile flows only, aligned to blueprint, not full web parity. +- `lot_transformations` still needs full create flow audit if expanded beyond current scope. +- Some modules are read-only where blueprint only required read paths. +- `pindah lokasi` and `ubah status` for lot detail are not implemented, and UI was intentionally removed. + +## Build / Deploy +- Android SDK path used locally: `/opt/android-sdk` +- Typical build command: + - `./gradlew assembleDebug` +- Typical install command: + - `adb install -r app/build/outputs/apk/debug/app-debug.apk` +- Last confirmed installed package timestamp during this session: + - `lastUpdateTime=2026-05-21 23:29:49` + +## Current Device +- Frequently used connected device: + - `CPH2781` (Oppo) + +## Notes For Next Codex +- Use `mobile-api-blueprint.md` as the primary spec. +- Use web project only as a secondary reference for payload shape or flow hints, not as the source of truth. +- Be careful with nested `Scaffold` padding/insets; several UI gaps came from double insets. +- Many server responses differ slightly between endpoints; avoid assuming detail response shape matches scan/list response shape. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..af9ac30 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,86 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "id.abelbirdnest.mobile" + compileSdk = 35 + + defaultConfig { + applicationId = "id.abelbirdnest.mobile" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-gson:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("androidx.camera:camera-camera2:1.4.1") + implementation("androidx.camera:camera-lifecycle:1.4.1") + implementation("androidx.camera:camera-view:1.4.1") + implementation("com.google.mlkit:barcode-scanning:17.3.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..eb4aef7 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally empty for now. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..30a1768 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/id/abelbirdnest/mobile/MainActivity.kt b/app/src/main/java/id/abelbirdnest/mobile/MainActivity.kt new file mode 100644 index 0000000..ce60248 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/MainActivity.kt @@ -0,0 +1,25 @@ +package id.abelbirdnest.mobile + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import id.abelbirdnest.mobile.ui.AbelbirdnestApp +import id.abelbirdnest.mobile.ui.MainViewModel +import id.abelbirdnest.mobile.ui.theme.AbelbirdnestTheme + +class MainActivity : ComponentActivity() { + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + AbelbirdnestTheme { + AbelbirdnestApp(viewModel = viewModel) + } + } + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/data/MobileRepository.kt b/app/src/main/java/id/abelbirdnest/mobile/data/MobileRepository.kt new file mode 100644 index 0000000..e6a220b --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/data/MobileRepository.kt @@ -0,0 +1,324 @@ +package id.abelbirdnest.mobile.data + +import android.content.Context +import id.abelbirdnest.mobile.data.toLotDetailData +import id.abelbirdnest.mobile.network.ApiFactory +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import com.google.gson.Gson +import retrofit2.HttpException + +class MobileRepository(context: Context) { + private val api = ApiFactory.create() + private val sessionStore = SessionStore(context) + private val gson = Gson() + + fun sessionToken(): String? = sessionStore.token() + + suspend fun login(identity: String, password: String): LoginData { + val response = api.login(LoginRequest(identity = identity, password = password)) + sessionStore.save(response.data) + return response.data + } + + suspend fun loadDashboardBundle(): DashboardBundle = coroutineScope { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + val auth = "Bearer $token" + + val bootstrap = async { api.bootstrap(auth).data } + val dashboard = async { api.dashboard(auth).data } + + val bootstrapData = bootstrap.await() + val dashboardData = dashboard.await() + val lotItems = if (bootstrapData.modules.contains("lots")) { + try { + api.lots(auth).data + } catch (error: HttpException) { + if (error.code() == 403) emptyList() else throw error + } + } else { + emptyList() + } + + DashboardBundle( + user = bootstrapData.user, + modules = bootstrapData.modules, + summary = bootstrapData.summary, + metrics = dashboardData.metrics, + criticalLots = dashboardData.criticalLots, + recentActivity = dashboardData.recentActivity, + gradeDistribution = lotItems + .filter { it.availableQty > 0 } + .groupBy { it.grade } + .mapValues { entry -> entry.value.sumOf { it.availableQty } } + .entries + .sortedByDescending { it.value } + .take(3) + .let { topGrades -> + val total = topGrades.sumOf { it.value }.takeIf { it > 0 } ?: 1.0 + topGrades.map { grade -> + GradeDistribution( + grade = grade.key, + quantity = grade.value, + percentage = ((grade.value / total) * 100).toInt(), + ) + } + }, + transformationTypes = bootstrapData.transformationTypes, + remainderModes = bootstrapData.remainderModes, + processingLossModes = bootstrapData.processingLossModes, + grades = bootstrapData.grades, + warehouses = bootstrapData.warehouses, + ) + } + + suspend fun loadLots(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.lots("Bearer $token").data + } + + suspend fun loadLotDetail(id: String): LotDetailData { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.lotDetail("Bearer $token", id).data.toLotDetailData() + } + + suspend fun scanLot(code: String): LotScanResult { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return LotScanResult( + scannedCode = code, + scannedAtMillis = System.currentTimeMillis(), + payload = api.scanLot("Bearer $token", code).data, + ) + } + + suspend fun loadAdjustmentReasons(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.adjustmentReasons("Bearer $token").data + } + + suspend fun loadStockAdjustments(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.stockAdjustments("Bearer $token").data + } + + suspend fun createStockAdjustment(payload: StockAdjustmentCreatePayload): StockAdjustmentListItem { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.createStockAdjustment("Bearer $token", payload).data + } + + suspend fun loadPurchases(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchases("Bearer $token").data + } + + suspend fun loadUnits(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.units("Bearer $token").data + } + + suspend fun loadEmployees(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.employees("Bearer $token").data + } + + suspend fun createPurchase(payload: PurchaseCreatePayload): PurchaseDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.createPurchase("Bearer $token", payload).data + } + + suspend fun loadPurchaseDetail(id: String): PurchaseDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchaseDetail("Bearer $token", id).data + } + + suspend fun updatePurchase(id: String, payload: PurchaseCreatePayload): PurchaseDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.updatePurchase("Bearer $token", id, payload).data + } + + suspend fun loadPurchaseAnalyses(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchaseAnalyses("Bearer $token").data + } + + suspend fun loadPurchaseAnalysisDetail(purchaseId: String): PurchaseAnalysisDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchaseAnalysisDetail("Bearer $token", purchaseId).data + } + + suspend fun loadPurchaseRealizations(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchaseRealizations("Bearer $token").data + } + + suspend fun loadPurchaseRealizationDetail(purchaseId: String): PurchaseRealizationDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.purchaseRealizationDetail("Bearer $token", purchaseId).data + } + + suspend fun loadRegularSales(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.regularSales("Bearer $token").data + } + + suspend fun loadRegularSaleDetail(id: String): RegularSaleDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.regularSaleDetail("Bearer $token", id).data + } + + suspend fun closeRegularSale(id: String, payload: RegularSaleClosePayload): RegularSaleDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.closeRegularSale("Bearer $token", id, payload).data + } + + suspend fun loadJitSales(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.jitSales("Bearer $token").data + } + + suspend fun loadJitSaleDetail(id: String): JitSaleDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.jitSaleDetail("Bearer $token", id).data + } + + suspend fun closeJitSale(id: String, payload: JitSaleClosePayload): JitSaleDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.closeJitSale("Bearer $token", id, payload).data + } + + suspend fun loadConsignmentsBootstrap(): ConsignmentBootstrapData { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.consignmentsBootstrap("Bearer $token").data + } + + suspend fun loadConsignments(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.consignments("Bearer $token").data + } + + suspend fun loadConsignmentDetail(id: String): ConsignmentDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.consignmentDetail("Bearer $token", id).data + } + + suspend fun closeConsignmentLine(lineId: String, payload: ConsignmentCloseLinePayload) { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + api.closeConsignmentLine("Bearer $token", lineId, payload) + } + + suspend fun loadFundRequestsBootstrap(): FundRequestsBootstrapData { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.fundRequestsBootstrap("Bearer $token").data + } + + suspend fun loadFundRequests(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.fundRequests("Bearer $token").data + } + + suspend fun createFundRequest( + transferType: String, + referenceNo: String, + agentId: String, + agentBankAccountId: String, + companyBankAccountId: String, + amount: String, + transferredAt: String, + ): FundRequestListItem { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + val plain = "text/plain".toMediaType() + val fields = linkedMapOf( + "transfer_type" to transferType.toRequestBody(plain), + "reference_no" to referenceNo.toRequestBody(plain), + "agent_id" to agentId.toRequestBody(plain), + "agent_bank_account_id" to agentBankAccountId.toRequestBody(plain), + "company_bank_account_id" to companyBankAccountId.toRequestBody(plain), + "amount" to amount.toRequestBody(plain), + "transferred_at" to transferredAt.toRequestBody(plain), + ) + return api.createFundRequest("Bearer $token", fields).data + } + + suspend fun submitPurchase(id: String): PurchaseDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.submitPurchase("Bearer $token", id).data + } + + suspend fun cancelPurchase(id: String) { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + api.cancelPurchase("Bearer $token", id) + } + + suspend fun loadLotTransformations(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.lotTransformations("Bearer $token").data + } + + suspend fun loadLotTransformationDetail(id: String): LotTransformationDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.lotTransformationDetail("Bearer $token", id).data + } + + suspend fun createLotTransformation(payload: LotTransformationCreatePayload): LotTransformationDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.createLotTransformation("Bearer $token", payload).data + } + + suspend fun loadWashingBootstrap(): WashingBootstrapData { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.washingBootstrap("Bearer $token").data + } + + suspend fun loadWashings(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.washings("Bearer $token").data + } + + suspend fun createWashing(payload: WashingCreatePayload): WashingListItem { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + val body = gson.toJson(payload).toRequestBody("text/plain".toMediaType()) + return api.createWashing("Bearer $token", body).data + } + + suspend fun updateWashing(id: String, payload: WashingCreatePayload): WashingListItem { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + val body = gson.toJson(payload).toRequestBody("text/plain".toMediaType()) + return api.updateWashing("Bearer $token", id, body).data + } + + suspend fun completeWashing(id: String, payload: CompleteWashingPayload): WashingListItem { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.completeWashing("Bearer $token", id, payload).data + } + + suspend fun loadReceiptsBootstrap(): ReceiptBootstrapData { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.receiptsBootstrap("Bearer $token").data + } + + suspend fun loadReceipts(): List { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.receipts("Bearer $token").data + } + + suspend fun loadReceiptDetail(id: String): ReceiptDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.receiptDetail("Bearer $token", id).data + } + + suspend fun createReceipt(payload: ReceiptCreatePayload): ReceiptDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.createReceipt("Bearer $token", payload).data + } + + suspend fun generateReceiptLots(id: String): ReceiptDetail { + val token = sessionStore.token() ?: error("Sesi tidak tersedia") + return api.generateReceiptLots("Bearer $token", id).data + } + + fun logout() { + sessionStore.clear() + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/data/Models.kt b/app/src/main/java/id/abelbirdnest/mobile/data/Models.kt new file mode 100644 index 0000000..8b58284 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/data/Models.kt @@ -0,0 +1,1152 @@ +package id.abelbirdnest.mobile.data + +import com.google.gson.annotations.SerializedName + +data class ApiEnvelope( + val data: T, +) + +data class LoginResponse( + val message: String, + val data: LoginData, +) + +data class LoginData( + val user: User, + @SerializedName("redirect_to") val redirectTo: String?, + @SerializedName("session_token") val sessionToken: String, + @SerializedName("token_type") val tokenType: String?, + @SerializedName("expires_in") val expiresIn: Int?, +) + +data class User( + val id: String, + val name: String, + val role: String, + val email: String?, + val username: String?, +) + +data class BootstrapData( + val user: User, + val modules: List = emptyList(), + val summary: Summary = Summary(), + @SerializedName("transformation_types") val transformationTypes: List = emptyList(), + @SerializedName("remainder_modes") val remainderModes: List = emptyList(), + @SerializedName("processing_loss_modes") val processingLossModes: List = emptyList(), + val grades: List = emptyList(), + val warehouses: List = emptyList(), +) + +data class Summary( + @SerializedName("active_lot_count") val activeLotCount: Int = 0, + @SerializedName("draft_purchase_count") val draftPurchaseCount: Int = 0, + @SerializedName("submitted_purchase_count") val submittedPurchaseCount: Int = 0, + @SerializedName("open_regular_sale_count") val openRegularSaleCount: Int = 0, + @SerializedName("open_consignment_line_count") val openConsignmentLineCount: Int = 0, + @SerializedName("in_progress_washing_count") val inProgressWashingCount: Int = 0, +) + +data class DashboardData( + val metrics: List = emptyList(), + @SerializedName("criticalLots") val criticalLots: List = emptyList(), + @SerializedName("recentActivity") val recentActivity: List = emptyList(), +) + +data class DashboardMetric( + val label: String, + val value: String, + val delta: String, +) + +data class CriticalLot( + val id: String, + @SerializedName("lotCode") val lotCode: String, + val supplier: String, + val item: String, + @SerializedName("availableQty") val availableQty: String, + @SerializedName("attentionStatus") val attentionStatus: String, + val reason: String, +) + +data class RecentActivity( + val id: String, + val summary: String, + @SerializedName("occurredAt") val occurredAt: String, +) + +data class LotItem( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val supplier: String, + val grade: String, + @SerializedName("original_qty") val originalQty: Double, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String, + @SerializedName("aging_days") val agingDays: Int, + val status: String, +) + +data class LotDetailEnvelope( + val data: LotDetailData, +) + +data class LotDetailData( + @SerializedName("summary_card") val summaryCard: LotSummaryCard?, + val lot: LotDetail, + val procurement: ProcurementInfo?, + @SerializedName("mobile_actions") val mobileActions: MobileActions?, +) + +data class LotDetailResponse( + @SerializedName("summary_card") val summaryCard: LotSummaryCard?, + val id: String, + @SerializedName("lot_code") val lotCode: String, + @SerializedName("source_type") val sourceType: String?, + val supplier: NamedEntity?, + val grade: NamedEntity?, + @SerializedName("original_qty") val originalQty: Double, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("reserved_qty") val reservedQty: Double, + @SerializedName("damaged_qty") val damagedQty: Double, + @SerializedName("shrinkage_qty") val shrinkageQty: Double, + @SerializedName("unit_code") val unitCode: String?, + @SerializedName("unit_cost") val unitCost: Double, + val warehouse: NamedEntity?, + val location: NamedEntity?, + val status: String, + @SerializedName("parent_lot") val parentLot: ParentLot?, + @SerializedName("child_lots") val childLots: List = emptyList(), + val labels: List = emptyList(), + val purchase: PurchaseInfo?, + val receipt: ReceiptInfo?, + @SerializedName("received_at") val receivedAt: String?, + val procurement: ProcurementInfo? = null, + @SerializedName("mobile_actions") val mobileActions: MobileActions? = null, +) + +data class LotSummaryCard( + @SerializedName("lot_code") val lotCode: String, + @SerializedName("source_type") val sourceType: String?, + val status: String, + val grade: String?, + @SerializedName("supplier_name") val supplierName: String?, + @SerializedName("warehouse_name") val warehouseName: String?, + @SerializedName("warehouse_location_name") val warehouseLocationName: String?, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("original_qty") val originalQty: Double, + @SerializedName("reserved_qty") val reservedQty: Double, + @SerializedName("damaged_qty") val damagedQty: Double, + @SerializedName("shrinkage_qty") val shrinkageQty: Double, + @SerializedName("unit_code") val unitCode: String?, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("estimated_value") val estimatedValue: Double, + @SerializedName("purchase_no") val purchaseNo: String?, + @SerializedName("purchase_date") val purchaseDate: String?, + @SerializedName("receipt_no") val receiptNo: String?, + @SerializedName("receipt_date") val receiptDate: String?, + @SerializedName("received_at") val receivedAt: String?, + @SerializedName("qr_code_value") val qrCodeValue: String?, + @SerializedName("barcode_value") val barcodeValue: String?, + @SerializedName("parent_lot_code") val parentLotCode: String?, + @SerializedName("child_lot_count") val childLotCount: Int, + @SerializedName("transformation_count") val transformationCount: Int, +) + +data class LotDetail( + val id: String, + @SerializedName("lot_code") val lotCode: String, + @SerializedName("source_type") val sourceType: String?, + val supplier: NamedEntity?, + val grade: NamedEntity?, + @SerializedName("original_qty") val originalQty: Double, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("reserved_qty") val reservedQty: Double, + @SerializedName("damaged_qty") val damagedQty: Double, + @SerializedName("shrinkage_qty") val shrinkageQty: Double, + @SerializedName("unit_code") val unitCode: String?, + @SerializedName("unit_cost") val unitCost: Double, + val warehouse: NamedEntity?, + val location: NamedEntity?, + val status: String, + @SerializedName("parent_lot") val parentLot: ParentLot?, + @SerializedName("child_lots") val childLots: List = emptyList(), + val labels: List = emptyList(), + val purchase: PurchaseInfo?, + val receipt: ReceiptInfo?, + @SerializedName("received_at") val receivedAt: String?, +) + +data class NamedEntity( + val id: String?, + val name: String?, +) + +data class ParentLot( + val id: String?, + @SerializedName("lot_code") val lotCode: String?, +) + +data class LotLabel( + val type: String, + val value: String, +) + +data class PurchaseInfo( + val id: String?, + @SerializedName("purchase_no") val purchaseNo: String?, + @SerializedName("purchase_date") val purchaseDate: String?, +) + +data class ReceiptInfo( + val id: String?, + @SerializedName("receipt_no") val receiptNo: String?, + @SerializedName("receipt_date") val receiptDate: String?, +) + +data class ProcurementInfo( + @SerializedName("supplier_name") val supplierName: String?, + @SerializedName("purchase_no") val purchaseNo: String?, + @SerializedName("purchase_date") val purchaseDate: String?, + @SerializedName("receipt_no") val receiptNo: String?, + @SerializedName("receipt_date") val receiptDate: String?, + @SerializedName("received_at") val receivedAt: String?, + @SerializedName("source_type") val sourceType: String?, +) + +data class MobileActions( + @SerializedName("can_mix") val canMix: Boolean = false, + @SerializedName("can_regrade") val canRegrade: Boolean = false, + @SerializedName("can_adjust") val canAdjust: Boolean = false, +) + +data class LotScanPayload( + @SerializedName("summary_card") val summaryCard: LotSummaryCard, + val lot: LotDetail, + val procurement: ProcurementInfo?, + @SerializedName("mobile_actions") val mobileActions: MobileActions?, +) + +data class LotScanResult( + val scannedCode: String, + val scannedAtMillis: Long, + val payload: LotScanPayload, +) + +data class LoginRequest( + val identity: String, + val password: String, +) + +data class DashboardBundle( + val user: User, + val modules: List, + val summary: Summary, + val metrics: List, + val criticalLots: List, + val recentActivity: List, + val gradeDistribution: List, + val transformationTypes: List, + val remainderModes: List, + val processingLossModes: List, + val grades: List, + val warehouses: List, +) + +data class GradeDistribution( + val grade: String, + val quantity: Double, + val percentage: Int, +) + +data class QuickAction( + val module: String, + val label: String, + val iconName: String, +) + +data class AdjustmentReason( + val id: String, + val code: String, + val name: String, + val category: String, + val status: String? = null, +) + +data class StockAdjustmentListItem( + val id: String, + @SerializedName("adjustment_no") val adjustmentNo: String, + @SerializedName("adjustment_date") val adjustmentDate: String, + val lot: StockAdjustmentLotRef, + val reason: AdjustmentReason, + @SerializedName("qty_change") val qtyChange: Double, + @SerializedName("available_qty_before") val availableQtyBefore: Double, + @SerializedName("available_qty_after") val availableQtyAfter: Double, + @SerializedName("created_by") val createdBy: StockAdjustmentCreator, + val notes: String?, +) + +data class StockAdjustmentLotRef( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("unit_code") val unitCode: String, +) + +data class StockAdjustmentCreator( + val id: String, + val name: String, + val role: String, +) + +data class StockAdjustmentCreatePayload( + @SerializedName("lot_id") val lotId: String, + @SerializedName("adjustment_reason_id") val adjustmentReasonId: String, + @SerializedName("adjustment_date") val adjustmentDate: String, + @SerializedName("qty_change") val qtyChange: Double, + val notes: String?, +) + +data class PurchaseListItem( + val id: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("received_at") val receivedAt: String?, + val agent: NamedEntity?, + val status: String, + @SerializedName("line_count") val lineCount: Int, + @SerializedName("grand_total") val grandTotal: Double, +) + +data class PurchaseDetail( + val id: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("supplier_invoice_no") val supplierInvoiceNo: String?, + val status: String, + val notes: String?, + @SerializedName("received_at") val receivedAt: String?, + @SerializedName("received_by_employee") val receivedByEmployee: NamedEntity?, + val agent: NamedEntity?, + @SerializedName("profit_share_scheme") val profitShareScheme: NamedEntity?, + val courier: NamedEntity?, + @SerializedName("moisture_buy_percent") val moistureBuyPercent: Double?, + @SerializedName("moisture_received_percent") val moistureReceivedPercent: Double?, + @SerializedName("above_average_ratio_percent") val aboveAverageRatioPercent: Double?, + @SerializedName("mk_share_percent") val mkSharePercent: Double?, + @SerializedName("non_mk_share_percent") val nonMkSharePercent: Double?, + @SerializedName("shipping_cost") val shippingCost: Double?, + @SerializedName("incoming_operational_cost") val incomingOperationalCost: Double?, + @SerializedName("after_arrival_operational_cost") val afterArrivalOperationalCost: Double?, + val analysis: PurchaseAnalysisSnapshot?, + val lines: List = emptyList(), +) + +data class PurchaseAnalysisSnapshot( + val id: String, + val status: String, + @SerializedName("weight_buy") val weightBuy: Double?, + @SerializedName("weight_received") val weightReceived: Double?, + @SerializedName("weight_final") val weightFinal: Double?, + @SerializedName("average_price") val averagePrice: Double?, + @SerializedName("modal_beli") val modalBeli: Double?, + @SerializedName("modal_masuk") val modalMasuk: Double?, + @SerializedName("modal_jual") val modalJual: Double?, + @SerializedName("modal_barang") val modalBarang: Double?, + @SerializedName("total_modal_beli") val totalModalBeli: Double?, + @SerializedName("total_modal_mal") val totalModalMal: Double?, + @SerializedName("market_reference_price") val marketReferencePrice: Double?, + @SerializedName("cost_lines") val costLines: List = emptyList(), +) + +data class PurchaseCostLine( + val id: String, + @SerializedName("cost_name") val costName: String, + val amount: Double, + val notes: String?, + @SerializedName("proof_file_url") val proofFileUrl: String?, +) + +data class PurchaseLineDetail( + val id: String, + val grade: NamedEntity?, + @SerializedName("qty_ordered") val qtyOrdered: Double, + @SerializedName("qty_received") val qtyReceived: Double, + @SerializedName("qty_accepted") val qtyAccepted: Double, + @SerializedName("qty_rejected") val qtyRejected: Double, + @SerializedName("purchase_moisture_percent") val purchaseMoisturePercent: Double?, + @SerializedName("moisture_received_percent") val moistureReceivedPercent: Double?, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("mal_unit_price") val malUnitPrice: Double?, + val unit: UnitLookup, + @SerializedName("unit_price") val unitPrice: Double, + val subtotal: Double, + @SerializedName("classification_status") val classificationStatus: String, + val warehouse: WarehouseLookup?, + @SerializedName("warehouse_location") val warehouseLocation: WarehouseLocationLookup?, + val notes: String?, +) + +data class PurchaseCreatePayload( + @SerializedName("received_by_employee_id") val receivedByEmployeeId: String, + @SerializedName("received_at") val receivedAt: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("warehouse_id") val warehouseId: String?, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, + val notes: String?, + val lines: List, +) + +data class PurchaseCreateLinePayload( + @SerializedName("grade_id") val gradeId: String?, + @SerializedName("qty_ordered") val qtyOrdered: Double, + @SerializedName("qty_received") val qtyReceived: Double, + @SerializedName("qty_accepted") val qtyAccepted: Double, + @SerializedName("qty_rejected") val qtyRejected: Double, + @SerializedName("unit_id") val unitId: String, + @SerializedName("unit_price") val unitPrice: Double, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("classification_status") val classificationStatus: String, + @SerializedName("warehouse_id") val warehouseId: String?, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, + val notes: String?, +) + +data class PurchaseAnalysisListItem( + @SerializedName("purchase_id") val purchaseId: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("supplier_name") val supplierName: String, + @SerializedName("purchase_status") val purchaseStatus: String, + @SerializedName("analysis_status") val analysisStatus: String, + @SerializedName("weight_buy") val weightBuy: Double?, + @SerializedName("weight_received") val weightReceived: Double?, + @SerializedName("weight_final") val weightFinal: Double?, + @SerializedName("total_modal_beli") val totalModalBeli: Double, + @SerializedName("total_modal_mal") val totalModalMal: Double?, + @SerializedName("total_laba_rugi") val totalLabaRugi: Double?, +) + +data class PurchaseAnalysisDetail( + val purchase: PurchaseAnalysisPurchaseRef, + val actuals: PurchaseAnalysisActuals, + val inputs: PurchaseAnalysisInputs, + val summary: PurchaseAnalysisSummary, +) + +data class PurchaseAnalysisPurchaseRef( + val id: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("supplier_name") val supplierName: String, + val status: String, + @SerializedName("grand_total") val grandTotal: Double, + @SerializedName("line_count") val lineCount: Int, +) + +data class PurchaseAnalysisActuals( + @SerializedName("weight_buy") val weightBuy: Double, + @SerializedName("weight_received") val weightReceived: Double, + @SerializedName("weight_final") val weightFinal: Double, + @SerializedName("moisture_buy_percent") val moistureBuyPercent: Double?, + @SerializedName("moisture_received_percent") val moistureReceivedPercent: Double?, + @SerializedName("moisture_final_percent") val moistureFinalPercent: Double?, + @SerializedName("above_average_ratio_percent") val aboveAverageRatioPercent: Double?, + @SerializedName("market_reference_price") val marketReferencePrice: Double?, +) + +data class PurchaseAnalysisInputs( + val status: String, + @SerializedName("weight_buy") val weightBuy: Double?, + @SerializedName("weight_received") val weightReceived: Double?, + @SerializedName("weight_final") val weightFinal: Double?, + @SerializedName("moisture_buy_percent") val moistureBuyPercent: Double?, + @SerializedName("moisture_received_percent") val moistureReceivedPercent: Double?, + @SerializedName("moisture_final_percent") val moistureFinalPercent: Double?, + @SerializedName("above_average_ratio_percent") val aboveAverageRatioPercent: Double?, + @SerializedName("average_price") val averagePrice: Double?, + @SerializedName("modal_beli") val modalBeli: Double?, + @SerializedName("modal_masuk") val modalMasuk: Double?, + @SerializedName("modal_jual") val modalJual: Double?, + @SerializedName("modal_barang") val modalBarang: Double?, + @SerializedName("total_modal_beli") val totalModalBeli: Double?, + @SerializedName("total_modal_mal") val totalModalMal: Double?, + @SerializedName("market_reference_price") val marketReferencePrice: Double?, + @SerializedName("market_valuation_total") val marketValuationTotal: Double?, + @SerializedName("agent_profit_share_total") val agentProfitShareTotal: Double, + val notes: String?, + @SerializedName("cost_entries") val costEntries: List = emptyList(), +) + +data class PurchaseAnalysisCostEntry( + val id: String, + @SerializedName("cost_type") val costType: String, + val description: String?, + val amount: Double, +) + +data class PurchaseAnalysisSummary( + @SerializedName("berat_beli") val beratBeli: Double?, + @SerializedName("berat_masuk") val beratMasuk: Double?, + @SerializedName("berat_akhir") val beratAkhir: Double?, + @SerializedName("berat_naik_percent") val beratNaikPercent: Double?, + @SerializedName("susut_tambah") val susutTambah: Double?, + @SerializedName("kadar_beli_percent") val kadarBeliPercent: Double?, + @SerializedName("kadar_masuk_percent") val kadarMasukPercent: Double?, + @SerializedName("kadar_akhir_percent") val kadarAkhirPercent: Double?, + @SerializedName("barang_atas_rata2_percent") val barangAtasRata2Percent: Double?, + @SerializedName("mk_percent") val mkPercent: Double?, + @SerializedName("non_mk_percent") val nonMkPercent: Double?, + @SerializedName("harga_mk_ar") val hargaMkAr: Double?, + @SerializedName("harga_rata2") val hargaRata2: Double?, + @SerializedName("modal_beli") val modalBeli: Double?, + @SerializedName("modal_masuk") val modalMasuk: Double?, + @SerializedName("modal_jual") val modalJual: Double?, + @SerializedName("modal_barang") val modalBarang: Double, + val operasional: Double, + @SerializedName("total_modal_beli") val totalModalBeli: Double, + @SerializedName("total_modal_mal") val totalModalMal: Double?, + @SerializedName("total_laba_rugi") val totalLabaRugi: Double?, + @SerializedName("laba_rugi_agen") val labaRugiAgen: Double, + @SerializedName("laba_total_per_kg") val labaTotalPerKg: Double?, + @SerializedName("laba_agen_per_kg") val labaAgenPerKg: Double?, +) + +data class PurchaseRealizationListItem( + @SerializedName("purchase_id") val purchaseId: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_type") val purchaseType: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("supplier_name") val supplierName: String, + val status: String, + @SerializedName("qty_opening") val qtyOpening: Double, + @SerializedName("qty_remaining") val qtyRemaining: Double, + @SerializedName("qty_sold") val qtySold: Double, + @SerializedName("qty_shrinkage") val qtyShrinkage: Double, + @SerializedName("revenue_total") val revenueTotal: Double, + @SerializedName("profit_loss_total") val profitLossTotal: Double, + @SerializedName("agent_profit_total") val agentProfitTotal: Double, +) + +data class PurchaseRealizationDetail( + val purchase: PurchaseRealizationPurchaseRef, + val summary: PurchaseRealizationSummary, + val entries: List = emptyList(), +) + +data class PurchaseRealizationPurchaseRef( + val id: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_type") val purchaseType: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("supplier_name") val supplierName: String, + val status: String, + @SerializedName("agent_name") val agentName: String?, + @SerializedName("buyout_source_agent_name") val buyoutSourceAgentName: String?, + @SerializedName("profit_share_scheme_name") val profitShareSchemeName: String?, + @SerializedName("share_agent_percent") val shareAgentPercent: Double?, +) + +data class PurchaseRealizationSummary( + val status: String, + @SerializedName("qty_opening") val qtyOpening: Double, + @SerializedName("qty_remaining") val qtyRemaining: Double, + @SerializedName("qty_sold") val qtySold: Double, + @SerializedName("qty_returned") val qtyReturned: Double, + @SerializedName("qty_shrinkage") val qtyShrinkage: Double, + @SerializedName("cost_opening_total") val costOpeningTotal: Double, + @SerializedName("cost_additional_total") val costAdditionalTotal: Double, + @SerializedName("revenue_total") val revenueTotal: Double, + @SerializedName("profit_loss_total") val profitLossTotal: Double, + @SerializedName("agent_share_percent") val agentSharePercent: Double?, + @SerializedName("agent_profit_total") val agentProfitTotal: Double, + @SerializedName("closed_at") val closedAt: String?, +) + +data class PurchaseRealizationEntryItem( + val id: String, + @SerializedName("event_type") val eventType: String, + @SerializedName("reference_type") val referenceType: String, + @SerializedName("reference_id") val referenceId: String?, + @SerializedName("lot_id") val lotId: String?, + @SerializedName("lot_code") val lotCode: String?, + @SerializedName("occurred_at") val occurredAt: String, + @SerializedName("qty_in") val qtyIn: Double, + @SerializedName("qty_out") val qtyOut: Double, + @SerializedName("qty_shrinkage") val qtyShrinkage: Double, + @SerializedName("amount_cost") val amountCost: Double, + @SerializedName("amount_revenue") val amountRevenue: Double, + @SerializedName("amount_expense") val amountExpense: Double, + @SerializedName("amount_profit") val amountProfit: Double, + @SerializedName("agent_amount") val agentAmount: Double, + val notes: String?, +) + +data class RegularSalePartyRef( + val id: String, + val code: String, + val name: String, +) + +data class RegularSaleListItem( + val id: String, + @SerializedName("sale_no") val saleNo: String, + @SerializedName("sale_date") val saleDate: String, + val buyer: RegularSalePartyRef, + @SerializedName("buyer_currency_code") val buyerCurrencyCode: String, + @SerializedName("company_currency_code") val companyCurrencyCode: String, + @SerializedName("exchange_rate") val exchangeRate: Double?, + val status: String, + @SerializedName("item_count") val itemCount: Int, + @SerializedName("total_nominal_buyer") val totalNominalBuyer: Double, + @SerializedName("total_nominal_company") val totalNominalCompany: Double, + @SerializedName("total_agent_commission") val totalAgentCommission: Double, +) + +data class RegularSaleDetail( + val id: String, + @SerializedName("sale_no") val saleNo: String, + @SerializedName("sale_date") val saleDate: String, + @SerializedName("close_date") val closeDate: String?, + val buyer: RegularSalePartyRef, + val courier: RegularSalePartyRef?, + @SerializedName("buyer_currency_code") val buyerCurrencyCode: String, + @SerializedName("company_currency_code") val companyCurrencyCode: String, + @SerializedName("exchange_rate") val exchangeRate: Double?, + @SerializedName("shipping_cost_buyer") val shippingCostBuyer: Double, + @SerializedName("shipping_cost_company") val shippingCostCompany: Double, + @SerializedName("shipping_receipt_file_url") val shippingReceiptFileUrl: String?, + @SerializedName("total_nominal_buyer") val totalNominalBuyer: Double, + @SerializedName("total_nominal_company") val totalNominalCompany: Double, + @SerializedName("total_agent_commission") val totalAgentCommission: Double, + val status: String, + val notes: String?, + @SerializedName("created_at") val createdAt: String, + val lines: List = emptyList(), +) + +data class RegularSaleLineDetail( + val id: String, + @SerializedName("lot_id") val lotId: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String?, + @SerializedName("available_qty_snapshot") val availableQtySnapshot: Double, + @SerializedName("current_available_qty") val currentAvailableQty: Double, + @SerializedName("mal_unit_price_snapshot") val malUnitPriceSnapshot: Double?, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("agent_name") val agentName: String?, + @SerializedName("agent_share_percent") val agentSharePercent: Double?, + @SerializedName("qty_planned") val qtyPlanned: Double, + @SerializedName("selling_price_planned") val sellingPricePlanned: Double, + @SerializedName("qty_actual_sold") val qtyActualSold: Double?, + @SerializedName("qty_returned") val qtyReturned: Double?, + @SerializedName("qty_shrinkage") val qtyShrinkage: Double?, + @SerializedName("selling_price_actual") val sellingPriceActual: Double?, + val notes: String?, +) + +data class RegularSaleClosePayload( + @SerializedName("close_date") val closeDate: String, + val lines: List, +) + +data class RegularSaleCloseLinePayload( + @SerializedName("line_id") val lineId: String, + @SerializedName("qty_actual_sold") val qtyActualSold: Double, + @SerializedName("qty_returned") val qtyReturned: Double, + @SerializedName("selling_price_actual") val sellingPriceActual: Double, +) + +data class JitSaleGradeRef( + val id: String, + val code: String, + val name: String, +) + +data class JitSaleListItem( + val id: String, + @SerializedName("sale_no") val saleNo: String, + @SerializedName("sale_date") val saleDate: String, + val buyer: RegularSalePartyRef, + @SerializedName("buyer_currency_code") val buyerCurrencyCode: String, + @SerializedName("company_currency_code") val companyCurrencyCode: String, + @SerializedName("exchange_rate") val exchangeRate: Double?, + val status: String, + @SerializedName("item_count") val itemCount: Int, + @SerializedName("total_nominal_buyer") val totalNominalBuyer: Double, + @SerializedName("total_nominal_company") val totalNominalCompany: Double, + @SerializedName("total_agent_commission") val totalAgentCommission: Double, +) + +data class JitSaleDetail( + val id: String, + @SerializedName("sale_no") val saleNo: String, + @SerializedName("sale_date") val saleDate: String, + @SerializedName("close_date") val closeDate: String?, + val buyer: RegularSalePartyRef, + val courier: RegularSalePartyRef?, + @SerializedName("buyer_currency_code") val buyerCurrencyCode: String, + @SerializedName("company_currency_code") val companyCurrencyCode: String, + @SerializedName("exchange_rate") val exchangeRate: Double?, + @SerializedName("shipping_cost_buyer") val shippingCostBuyer: Double, + @SerializedName("shipping_cost_company") val shippingCostCompany: Double, + @SerializedName("shipping_receipt_file_url") val shippingReceiptFileUrl: String?, + @SerializedName("total_nominal_buyer") val totalNominalBuyer: Double, + @SerializedName("total_nominal_company") val totalNominalCompany: Double, + @SerializedName("total_agent_commission") val totalAgentCommission: Double, + val status: String, + val notes: String?, + @SerializedName("created_at") val createdAt: String, + val lines: List = emptyList(), +) + +data class JitSaleLineDetail( + val id: String, + val grade: JitSaleGradeRef, + @SerializedName("qty_planned") val qtyPlanned: Double, + @SerializedName("qty_actual_sold") val qtyActualSold: Double?, + @SerializedName("mal_unit_price") val malUnitPrice: Double, + @SerializedName("selling_price_planned") val sellingPricePlanned: Double, + @SerializedName("selling_price_actual") val sellingPriceActual: Double?, + val agent: RegularSalePartyRef?, + @SerializedName("profit_share_scheme") val profitShareScheme: RegularSalePartyRef?, + @SerializedName("agent_name") val agentName: String?, + @SerializedName("agent_share_percent") val agentSharePercent: Double?, + val notes: String?, +) + +data class JitSaleClosePayload( + @SerializedName("close_date") val closeDate: String, + val lines: List, +) + +data class JitSaleCloseLinePayload( + @SerializedName("line_id") val lineId: String, + @SerializedName("selling_price_actual") val sellingPriceActual: Double, +) + +data class ConsignmentCandidateLot( + @SerializedName("lot_id") val lotId: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String?, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("mal_unit_price") val malUnitPrice: Double?, +) + +data class ConsignmentListItem( + val id: String, + @SerializedName("consignment_no") val consignmentNo: String, + @SerializedName("consignment_date") val consignmentDate: String, + val sales: RegularSalePartyRef, + val buyer: RegularSalePartyRef, + val status: String, + @SerializedName("item_count") val itemCount: Int, + @SerializedName("open_item_count") val openItemCount: Int, + @SerializedName("total_qty_consigned") val totalQtyConsigned: Double, + @SerializedName("total_qty_sold") val totalQtySold: Double, + @SerializedName("total_qty_returned") val totalQtyReturned: Double, + @SerializedName("total_qty_shrinkage") val totalQtyShrinkage: Double, +) + +data class ConsignmentDetail( + val id: String, + @SerializedName("consignment_no") val consignmentNo: String, + @SerializedName("consignment_date") val consignmentDate: String, + val sales: RegularSalePartyRef, + val buyer: RegularSalePartyRef, + val status: String, + val notes: String?, + @SerializedName("created_at") val createdAt: String, + val lines: List = emptyList(), +) + +data class ConsignmentLineDetail( + val id: String, + @SerializedName("lot_id") val lotId: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String?, + @SerializedName("available_qty_snapshot") val availableQtySnapshot: Double, + @SerializedName("current_available_qty") val currentAvailableQty: Double, + @SerializedName("qty_consigned") val qtyConsigned: Double, + @SerializedName("mal_unit_price_snapshot") val malUnitPriceSnapshot: Double?, + @SerializedName("agent_name_snapshot") val agentNameSnapshot: String?, + @SerializedName("agent_share_percent") val agentSharePercent: Double?, + @SerializedName("unit_cost") val unitCost: Double, + val status: String, + val notes: String?, + @SerializedName("close_date") val closeDate: String?, + @SerializedName("selling_price") val sellingPrice: Double?, + @SerializedName("qty_sold") val qtySold: Double, + @SerializedName("qty_returned") val qtyReturned: Double, + @SerializedName("qty_shrinkage") val qtyShrinkage: Double, + @SerializedName("sales_commission") val salesCommission: Double, + @SerializedName("agent_commission") val agentCommission: Double, +) + +data class ConsignmentBootstrapData( + val sales: List = emptyList(), + val buyers: List = emptyList(), + val lots: List = emptyList(), +) + +data class ConsignmentCloseLinePayload( + @SerializedName("close_date") val closeDate: String, + @SerializedName("selling_price") val sellingPrice: Double, + @SerializedName("qty_sold") val qtySold: Double, + @SerializedName("qty_returned") val qtyReturned: Double, + @SerializedName("sales_commission") val salesCommission: Double, +) + +data class FundRequestBankAccountOption( + val id: String, + @SerializedName("bank_id") val bankId: String?, + @SerializedName("bank_code") val bankCode: String?, + @SerializedName("bank_name") val bankName: String, + @SerializedName("account_number") val accountNumber: String, +) + +data class FundRequestAgentOption( + val id: String, + val code: String, + val name: String, + @SerializedName("profit_share_balance") val profitShareBalance: Double = 0.0, + @SerializedName("capital_balance") val capitalBalance: Double = 0.0, + @SerializedName("bank_accounts") val bankAccounts: List = emptyList(), +) + +data class FundRequestsBootstrapData( + val agents: List = emptyList(), + @SerializedName("company_bank_name") val companyBankName: String?, + @SerializedName("company_bank_account_number") val companyBankAccountNumber: String?, + @SerializedName("company_bank_accounts") val companyBankAccounts: List = emptyList(), + @SerializedName("currency_code") val currencyCode: String = "IDR", +) + +data class FundRequestListItem( + val id: String, + @SerializedName("request_no") val requestNo: String, + @SerializedName("reference_no") val referenceNo: String, + @SerializedName("transfer_type") val transferType: String, + val agent: RegularSalePartyRef, + @SerializedName("agent_bank_account") val agentBankAccount: FundRequestBankAccountOption, + @SerializedName("company_bank_account") val companyBankAccount: FundRequestBankAccountOption?, + @SerializedName("company_bank_name") val companyBankName: String, + @SerializedName("company_bank_account_number") val companyBankAccountNumber: String, + val amount: Double, + @SerializedName("currency_code") val currencyCode: String, + @SerializedName("transferred_at") val transferredAt: String, + @SerializedName("transfer_proof_file_url") val transferProofFileUrl: String?, + val status: String, + @SerializedName("created_at") val createdAt: String, +) + +data class LotTransformationListItem( + val id: String, + @SerializedName("transformation_no") val transformationNo: String, + @SerializedName("transformation_type") val transformationType: String, + @SerializedName("transformation_date") val transformationDate: String, + val status: String, + @SerializedName("input_count") val inputCount: Int, + @SerializedName("output_count") val outputCount: Int, + @SerializedName("total_input_qty") val totalInputQty: Double, + @SerializedName("total_output_qty") val totalOutputQty: Double, +) + +data class LotTransformationDetail( + val id: String, + @SerializedName("transformation_no") val transformationNo: String, + @SerializedName("transformation_type") val transformationType: String, + @SerializedName("transformation_date") val transformationDate: String, + val status: String, + @SerializedName("remainder_mode") val remainderMode: String?, + @SerializedName("remainder_qty") val remainderQty: Double?, + @SerializedName("processing_loss_mode") val processingLossMode: String?, + @SerializedName("processing_loss_qty") val processingLossQty: Double?, + val notes: String?, + val inputs: List = emptyList(), + val outputs: List = emptyList(), +) + +data class LotTransformationCreatePayload( + @SerializedName("transformation_type") val transformationType: String, + @SerializedName("transformation_date") val transformationDate: String, + @SerializedName("remainder_mode") val remainderMode: String?, + @SerializedName("processing_loss_mode") val processingLossMode: String?, + val notes: String?, + val inputs: List, + val outputs: List, +) + +data class LotTransformationCreateInputPayload( + @SerializedName("source_lot_code") val sourceLotCode: String, + @SerializedName("qty_used") val qtyUsed: Double, + val notes: String?, +) + +data class LotTransformationCreateOutputPayload( + @SerializedName("grade_id") val gradeId: String, + @SerializedName("warehouse_id") val warehouseId: String, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, + @SerializedName("qty_produced") val qtyProduced: Double, + val notes: String?, +) + +data class LotTransformationInput( + val id: String, + @SerializedName("source_lot") val sourceLot: LotTransformationLotRef, + @SerializedName("qty_used") val qtyUsed: Double, + @SerializedName("unit_cost_snapshot") val unitCostSnapshot: Double, + val notes: String?, +) + +data class LotTransformationOutput( + val id: String, + @SerializedName("result_lot") val resultLot: LotTransformationResultLotRef, + @SerializedName("qty_produced") val qtyProduced: Double, + @SerializedName("unit_cost") val unitCost: Double, + val notes: String?, +) + +data class LotTransformationLotRef( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String?, +) + +data class LotTransformationResultLotRef( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val grade: String, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("unit_code") val unitCode: String, + val warehouse: String, + val location: String?, + val status: String, +) + +data class ReceiptBootstrapData( + val purchases: List = emptyList(), + val warehouses: List = emptyList(), +) + +data class ReceiptPurchaseOption( + val id: String, + @SerializedName("purchase_no") val purchaseNo: String, + @SerializedName("purchase_date") val purchaseDate: String, + @SerializedName("received_at") val receivedAt: String?, + @SerializedName("supplier_name") val supplierName: String?, + val notes: String?, + val lines: List = emptyList(), +) + +data class ReceiptPurchaseLineOption( + val id: String, + @SerializedName("purchase_line_id") val purchaseLineId: String, + val grade: LookupRecord?, + val unit: UnitLookup, + @SerializedName("qty_ordered") val qtyOrdered: Double, + @SerializedName("qty_received") val qtyReceived: Double, + @SerializedName("qty_accepted") val qtyAccepted: Double, + @SerializedName("qty_rejected") val qtyRejected: Double, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("warehouse_id") val warehouseId: String?, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, +) + +data class UnitLookup( + val id: String, + val code: String, + val name: String? = null, +) + +data class ReceiptListItem( + val id: String, + @SerializedName("receipt_no") val receiptNo: String, + val purchase: PurchaseInfo, + @SerializedName("receipt_date") val receiptDate: String, + val status: String, + @SerializedName("line_count") val lineCount: Int, + @SerializedName("lot_count") val lotCount: Int, +) + +data class ReceiptDetail( + val id: String, + @SerializedName("receipt_no") val receiptNo: String, + val purchase: PurchaseInfo, + @SerializedName("receipt_date") val receiptDate: String, + val status: String, + val notes: String?, + val lines: List = emptyList(), + @SerializedName("generated_lots") val generatedLots: List = emptyList(), +) + +data class ReceiptDetailLine( + val id: String, + @SerializedName("purchase_line_id") val purchaseLineId: String, + val grade: NamedEntity?, + @SerializedName("qty_received") val qtyReceived: Double, + @SerializedName("qty_accepted") val qtyAccepted: Double, + @SerializedName("qty_rejected") val qtyRejected: Double, + val unit: UnitLookup, + @SerializedName("unit_cost") val unitCost: Double, + val warehouse: NamedEntity?, + val location: NamedEntity?, + val notes: String?, +) + +data class ReceiptGeneratedLot( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val status: String, +) + +data class ReceiptCreatePayload( + @SerializedName("purchase_id") val purchaseId: String, + @SerializedName("receipt_date") val receiptDate: String, + val notes: String?, + val lines: List, +) + +data class ReceiptCreateLinePayload( + @SerializedName("purchase_line_id") val purchaseLineId: String, + @SerializedName("grade_id") val gradeId: String?, + @SerializedName("qty_received") val qtyReceived: Double, + @SerializedName("qty_accepted") val qtyAccepted: Double, + @SerializedName("qty_rejected") val qtyRejected: Double, + @SerializedName("unit_id") val unitId: String, + @SerializedName("unit_cost") val unitCost: Double, + @SerializedName("warehouse_id") val warehouseId: String, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, + val notes: String?, +) + +data class WashingListItem( + val id: String, + @SerializedName("washing_no") val washingNo: String, + val status: String, + @SerializedName("started_at") val startedAt: String, + @SerializedName("expected_done_at") val expectedDoneAt: String, + @SerializedName("completed_at") val completedAt: String?, + @SerializedName("washing_cost") val washingCost: Double, + @SerializedName("duration_hours") val durationHours: Int, + @SerializedName("receipt_file_url") val receiptFileUrl: String?, + @SerializedName("before_qty") val beforeQty: Double, + @SerializedName("after_qty") val afterQty: Double?, + @SerializedName("shrinkage_qty") val shrinkageQty: Double?, + @SerializedName("before_grade_name") val beforeGradeName: String?, + @SerializedName("after_grade_name") val afterGradeName: String?, + @SerializedName("before_warehouse_name") val beforeWarehouseName: String, + @SerializedName("before_location_name") val beforeLocationName: String?, + @SerializedName("after_warehouse_name") val afterWarehouseName: String?, + @SerializedName("after_location_name") val afterLocationName: String?, + val lot: WashingLotRef, + @SerializedName("washing_place") val washingPlace: WashingPlaceRef, +) + +data class WashingLotRef( + val id: String, + @SerializedName("lot_code") val lotCode: String, + val supplier: String, + @SerializedName("grade_name") val gradeName: String?, + @SerializedName("available_qty") val availableQty: Double, + @SerializedName("unit_code") val unitCode: String, + @SerializedName("warehouse_name") val warehouseName: String, + @SerializedName("location_name") val locationName: String?, +) + +data class WashingPlaceRef( + val id: String, + val name: String, +) + +data class WashingBootstrapData( + @SerializedName("washing_places") val washingPlaces: List = emptyList(), + val grades: List = emptyList(), + val warehouses: List = emptyList(), +) + +data class LookupRecord( + val id: String, + val code: String? = null, + val name: String, +) + +data class CodeLabelOption( + val code: String, + val label: String, +) + +data class WarehouseLookup( + val id: String, + val code: String? = null, + val name: String, + val locations: List = emptyList(), +) + +data class WarehouseLocationLookup( + val id: String, + val code: String? = null, + val name: String, + @SerializedName("location_type") val locationType: String? = null, +) + +data class WashingCreatePayload( + @SerializedName("lot_id") val lotId: String, + @SerializedName("washing_place_id") val washingPlaceId: String, + @SerializedName("washing_cost") val washingCost: Double, + @SerializedName("duration_hours") val durationHours: Int, +) + +data class CompleteWashingPayload( + @SerializedName("after_qty") val afterQty: Double, + @SerializedName("grade_id") val gradeId: String?, + @SerializedName("warehouse_id") val warehouseId: String, + @SerializedName("warehouse_location_id") val warehouseLocationId: String?, +) + +fun LotDetailResponse.toLotDetailData(): LotDetailData { + return LotDetailData( + summaryCard = summaryCard, + lot = LotDetail( + id = id, + lotCode = lotCode, + sourceType = sourceType, + supplier = supplier, + grade = grade, + originalQty = originalQty, + availableQty = availableQty, + reservedQty = reservedQty, + damagedQty = damagedQty, + shrinkageQty = shrinkageQty, + unitCode = unitCode, + unitCost = unitCost, + warehouse = warehouse, + location = location, + status = status, + parentLot = parentLot, + childLots = childLots, + labels = labels, + purchase = purchase, + receipt = receipt, + receivedAt = receivedAt, + ), + procurement = procurement, + mobileActions = mobileActions, + ) +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/data/SessionStore.kt b/app/src/main/java/id/abelbirdnest/mobile/data/SessionStore.kt new file mode 100644 index 0000000..668c095 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/data/SessionStore.kt @@ -0,0 +1,27 @@ +package id.abelbirdnest.mobile.data + +import android.content.Context + +class SessionStore(context: Context) { + private val prefs = context.getSharedPreferences("abelbirdnest_stock_session", Context.MODE_PRIVATE) + + fun save(loginData: LoginData) { + prefs.edit() + .putString(KEY_TOKEN, loginData.sessionToken) + .putString(KEY_NAME, loginData.user.name) + .putString(KEY_ROLE, loginData.user.role) + .apply() + } + + fun token(): String? = prefs.getString(KEY_TOKEN, null) + + fun clear() { + prefs.edit().clear().apply() + } + + companion object { + private const val KEY_TOKEN = "token" + private const val KEY_NAME = "name" + private const val KEY_ROLE = "role" + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/network/ApiService.kt b/app/src/main/java/id/abelbirdnest/mobile/network/ApiService.kt new file mode 100644 index 0000000..917c720 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/network/ApiService.kt @@ -0,0 +1,352 @@ +package id.abelbirdnest.mobile.network + +import id.abelbirdnest.mobile.data.ApiEnvelope +import id.abelbirdnest.mobile.data.BootstrapData +import id.abelbirdnest.mobile.data.CompleteWashingPayload +import id.abelbirdnest.mobile.data.ConsignmentBootstrapData +import id.abelbirdnest.mobile.data.ConsignmentCloseLinePayload +import id.abelbirdnest.mobile.data.ConsignmentDetail +import id.abelbirdnest.mobile.data.ConsignmentListItem +import id.abelbirdnest.mobile.data.DashboardData +import id.abelbirdnest.mobile.data.FundRequestListItem +import id.abelbirdnest.mobile.data.FundRequestsBootstrapData +import id.abelbirdnest.mobile.data.JitSaleClosePayload +import id.abelbirdnest.mobile.data.JitSaleDetail +import id.abelbirdnest.mobile.data.JitSaleListItem +import id.abelbirdnest.mobile.data.LookupRecord +import id.abelbirdnest.mobile.data.LotDetailResponse +import id.abelbirdnest.mobile.data.LotTransformationCreatePayload +import id.abelbirdnest.mobile.data.LotTransformationDetail +import id.abelbirdnest.mobile.data.LotTransformationListItem +import id.abelbirdnest.mobile.data.AdjustmentReason +import id.abelbirdnest.mobile.data.PurchaseAnalysisDetail +import id.abelbirdnest.mobile.data.PurchaseAnalysisListItem +import id.abelbirdnest.mobile.data.PurchaseCreatePayload +import id.abelbirdnest.mobile.data.PurchaseDetail +import id.abelbirdnest.mobile.data.PurchaseListItem +import id.abelbirdnest.mobile.data.PurchaseRealizationDetail +import id.abelbirdnest.mobile.data.PurchaseRealizationListItem +import id.abelbirdnest.mobile.data.RegularSaleClosePayload +import id.abelbirdnest.mobile.data.RegularSaleDetail +import id.abelbirdnest.mobile.data.RegularSaleListItem +import id.abelbirdnest.mobile.data.ReceiptBootstrapData +import id.abelbirdnest.mobile.data.StockAdjustmentCreatePayload +import id.abelbirdnest.mobile.data.StockAdjustmentListItem +import id.abelbirdnest.mobile.data.ReceiptCreatePayload +import id.abelbirdnest.mobile.data.ReceiptDetail +import id.abelbirdnest.mobile.data.ReceiptListItem +import id.abelbirdnest.mobile.data.LoginRequest +import id.abelbirdnest.mobile.data.LoginResponse +import id.abelbirdnest.mobile.data.LotItem +import id.abelbirdnest.mobile.data.LotScanPayload +import id.abelbirdnest.mobile.data.UnitLookup +import id.abelbirdnest.mobile.data.WashingBootstrapData +import id.abelbirdnest.mobile.data.WashingListItem +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.Part +import retrofit2.http.PartMap +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.PUT + +interface ApiService { + @POST("auth/login") + suspend fun login( + @Body request: LoginRequest, + ): LoginResponse + + @GET("mobile/bootstrap") + suspend fun bootstrap( + @Header("Authorization") authorization: String, + ): ApiEnvelope + + @GET("mobile/dashboard") + suspend fun dashboard( + @Header("Authorization") authorization: String, + @Query("locale") locale: String = "id", + ): ApiEnvelope + + @GET("mobile/lots") + suspend fun lots( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/lots/{id}") + suspend fun lotDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @GET("mobile/lots/scan") + suspend fun scanLot( + @Header("Authorization") authorization: String, + @Query("code") code: String, + ): ApiEnvelope + + @GET("adjustment-reasons") + suspend fun adjustmentReasons( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/stock-adjustments") + suspend fun stockAdjustments( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @POST("mobile/stock-adjustments") + suspend fun createStockAdjustment( + @Header("Authorization") authorization: String, + @Body payload: StockAdjustmentCreatePayload, + ): ApiEnvelope + + @GET("mobile/purchases") + suspend fun purchases( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("units") + suspend fun units( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("employees") + suspend fun employees( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @POST("mobile/purchases") + suspend fun createPurchase( + @Header("Authorization") authorization: String, + @Body payload: PurchaseCreatePayload, + ): ApiEnvelope + + @GET("mobile/purchases/{id}") + suspend fun purchaseDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @PUT("mobile/purchases/{id}") + suspend fun updatePurchase( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Body payload: PurchaseCreatePayload, + ): ApiEnvelope + + @GET("mobile/purchase-analyses") + suspend fun purchaseAnalyses( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/purchase-analyses/{purchaseId}") + suspend fun purchaseAnalysisDetail( + @Header("Authorization") authorization: String, + @Path("purchaseId") purchaseId: String, + ): ApiEnvelope + + @GET("mobile/purchase-realizations") + suspend fun purchaseRealizations( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/purchase-realizations/{purchaseId}") + suspend fun purchaseRealizationDetail( + @Header("Authorization") authorization: String, + @Path("purchaseId") purchaseId: String, + ): ApiEnvelope + + @GET("mobile/sales-regular") + suspend fun regularSales( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/sales-regular/{id}") + suspend fun regularSaleDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("mobile/sales-regular/{id}/close") + suspend fun closeRegularSale( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Body payload: RegularSaleClosePayload, + ): ApiEnvelope + + @GET("mobile/sales-jit") + suspend fun jitSales( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/sales-jit/{id}") + suspend fun jitSaleDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("mobile/sales-jit/{id}/close") + suspend fun closeJitSale( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Body payload: JitSaleClosePayload, + ): ApiEnvelope + + @GET("mobile/consignments/bootstrap") + suspend fun consignmentsBootstrap( + @Header("Authorization") authorization: String, + ): ApiEnvelope + + @GET("mobile/consignments") + suspend fun consignments( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/consignments/{id}") + suspend fun consignmentDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("consignments/lines/{lineId}/close") + suspend fun closeConsignmentLine( + @Header("Authorization") authorization: String, + @Path("lineId") lineId: String, + @Body payload: ConsignmentCloseLinePayload, + ): ApiEnvelope> + + @GET("mobile/fund-requests/bootstrap") + suspend fun fundRequestsBootstrap( + @Header("Authorization") authorization: String, + ): ApiEnvelope + + @GET("mobile/fund-requests") + suspend fun fundRequests( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @Multipart + @POST("mobile/fund-requests") + suspend fun createFundRequest( + @Header("Authorization") authorization: String, + @PartMap fields: Map, + ): ApiEnvelope + + @POST("mobile/purchases/{id}/submit") + suspend fun submitPurchase( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("mobile/purchases/{id}/cancel") + suspend fun cancelPurchase( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope> + + @GET("mobile/lot-transformations") + suspend fun lotTransformations( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @GET("mobile/lot-transformations/{id}") + suspend fun lotTransformationDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("mobile/lot-transformations") + suspend fun createLotTransformation( + @Header("Authorization") authorization: String, + @Body payload: LotTransformationCreatePayload, + ): ApiEnvelope + + @GET("mobile/washing/bootstrap") + suspend fun washingBootstrap( + @Header("Authorization") authorization: String, + ): ApiEnvelope + + @GET("mobile/washing") + suspend fun washings( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @Multipart + @POST("mobile/washing") + suspend fun createWashing( + @Header("Authorization") authorization: String, + @Part("payload") payload: RequestBody, + ): ApiEnvelope + + @Multipart + @PUT("mobile/washing/{id}") + suspend fun updateWashing( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Part("payload") payload: RequestBody, + ): ApiEnvelope + + @POST("mobile/washing/{id}/complete") + suspend fun completeWashing( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Body payload: CompleteWashingPayload, + ): ApiEnvelope + + @GET("mobile/receipts/bootstrap") + suspend fun receiptsBootstrap( + @Header("Authorization") authorization: String, + ): ApiEnvelope + + @GET("mobile/receipts") + suspend fun receipts( + @Header("Authorization") authorization: String, + ): ApiEnvelope> + + @POST("mobile/receipts") + suspend fun createReceipt( + @Header("Authorization") authorization: String, + @Body payload: ReceiptCreatePayload, + ): ApiEnvelope + + @GET("mobile/receipts/{id}") + suspend fun receiptDetail( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope + + @POST("mobile/receipts/{id}/generate-lots") + suspend fun generateReceiptLots( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): ApiEnvelope +} + +object ApiFactory { + private const val BASE_URL = "https://abelbirdnest.id/api/v1/" + + fun create(): ApiService { + val logger = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + val client = OkHttpClient.Builder() + .addInterceptor(logger) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiService::class.java) + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/ui/AbelbirdnestApp.kt b/app/src/main/java/id/abelbirdnest/mobile/ui/AbelbirdnestApp.kt new file mode 100644 index 0000000..02b448d --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/ui/AbelbirdnestApp.kt @@ -0,0 +1,7667 @@ +package id.abelbirdnest.mobile.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +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.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.ContentPasteSearch +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.EditNote +import androidx.compose.material.icons.outlined.ExitToApp +import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LocationOn +import androidx.compose.material.icons.outlined.Login +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Print +import androidx.compose.material.icons.outlined.QrCode2 +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material.icons.outlined.ReceiptLong +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.ShowChart +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material.icons.outlined.WaterDrop +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import id.abelbirdnest.mobile.BuildConfig +import id.abelbirdnest.mobile.R +import id.abelbirdnest.mobile.data.CriticalLot +import id.abelbirdnest.mobile.data.DashboardBundle +import id.abelbirdnest.mobile.data.GradeDistribution +import id.abelbirdnest.mobile.data.AdjustmentReason +import id.abelbirdnest.mobile.data.ConsignmentDetail +import id.abelbirdnest.mobile.data.ConsignmentLineDetail +import id.abelbirdnest.mobile.data.ConsignmentListItem +import id.abelbirdnest.mobile.data.FundRequestAgentOption +import id.abelbirdnest.mobile.data.FundRequestBankAccountOption +import id.abelbirdnest.mobile.data.FundRequestListItem +import id.abelbirdnest.mobile.data.JitSaleDetail +import id.abelbirdnest.mobile.data.JitSaleLineDetail +import id.abelbirdnest.mobile.data.JitSaleListItem +import id.abelbirdnest.mobile.data.LotDetail +import id.abelbirdnest.mobile.data.LotDetailData +import id.abelbirdnest.mobile.data.LotItem +import id.abelbirdnest.mobile.data.LotScanResult +import id.abelbirdnest.mobile.data.LotTransformationDetail +import id.abelbirdnest.mobile.data.LotTransformationInput +import id.abelbirdnest.mobile.data.LotTransformationListItem +import id.abelbirdnest.mobile.data.LotTransformationOutput +import id.abelbirdnest.mobile.data.PurchaseAnalysisDetail +import id.abelbirdnest.mobile.data.PurchaseAnalysisListItem +import id.abelbirdnest.mobile.data.PurchaseDetail +import id.abelbirdnest.mobile.data.PurchaseLineDetail +import id.abelbirdnest.mobile.data.PurchaseListItem +import id.abelbirdnest.mobile.data.PurchaseRealizationDetail +import id.abelbirdnest.mobile.data.PurchaseRealizationEntryItem +import id.abelbirdnest.mobile.data.PurchaseRealizationListItem +import id.abelbirdnest.mobile.data.QuickAction +import id.abelbirdnest.mobile.data.RegularSaleDetail +import id.abelbirdnest.mobile.data.RegularSaleLineDetail +import id.abelbirdnest.mobile.data.RegularSaleListItem +import id.abelbirdnest.mobile.data.LookupRecord +import id.abelbirdnest.mobile.data.UnitLookup +import id.abelbirdnest.mobile.data.WarehouseLookup +import id.abelbirdnest.mobile.data.WashingListItem +import id.abelbirdnest.mobile.data.ReceiptDetail +import id.abelbirdnest.mobile.data.ReceiptDetailLine +import id.abelbirdnest.mobile.data.ReceiptGeneratedLot +import id.abelbirdnest.mobile.data.ReceiptListItem +import id.abelbirdnest.mobile.data.ReceiptPurchaseOption +import id.abelbirdnest.mobile.data.StockAdjustmentListItem +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.Executors +import kotlin.math.roundToInt +import androidx.compose.ui.window.Dialog + +@Composable +fun AbelbirdnestApp(viewModel: MainViewModel) { + val state = viewModel.uiState + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.errorMessage) { + val message = state.errorMessage ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + viewModel.clearError() + } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(innerPadding), + ) { + when { + state.isCheckingSession -> LoadingScreen() + state.isAuthenticated && state.dashboard != null -> ShellScreen( + data = state.dashboard, + currentModule = state.currentModule, + isRefreshing = state.isRefreshing, + inlineError = state.errorMessage, + lotsState = state.lotsState, + salesRegularState = state.salesRegularState, + salesJitState = state.salesJitState, + consignmentsState = state.consignmentsState, + fundRequestsState = state.fundRequestsState, + purchasesState = state.purchasesState, + purchaseAnalysesState = state.purchaseAnalysesState, + purchaseRealizationsState = state.purchaseRealizationsState, + lotTransformationsState = state.lotTransformationsState, + stockAdjustmentsState = state.stockAdjustmentsState, + washingState = state.washingState, + receiptsState = state.receiptsState, + onRefresh = viewModel::refreshDashboard, + onLogout = viewModel::logout, + onModuleSelected = viewModel::selectModule, + onRefreshLots = viewModel::refreshLots, + onLotQueryChanged = viewModel::updateLotQuery, + onLotScanInputChanged = viewModel::updateScanInput, + onScanLot = viewModel::scanLot, + onScanLotCode = viewModel::scanLotCode, + onOpenLotDetail = viewModel::openLotDetail, + onCloseLotDetail = viewModel::closeLotDetail, + onOpenRecentScan = viewModel::openRecentScan, + onRefreshRegularSales = viewModel::refreshRegularSales, + onOpenRegularSaleDetail = viewModel::openRegularSaleDetail, + onCloseRegularSaleDetail = viewModel::closeRegularSaleDetail, + onUpdateRegularSaleCloseDate = viewModel::updateRegularSaleCloseDate, + onUpdateRegularSaleQtySold = viewModel::updateRegularSaleQtySold, + onUpdateRegularSaleQtyReturned = viewModel::updateRegularSaleQtyReturned, + onUpdateRegularSalePriceActual = viewModel::updateRegularSalePriceActual, + onCloseRegularSale = viewModel::closeRegularSale, + onRefreshJitSales = viewModel::refreshJitSales, + onOpenJitSaleDetail = viewModel::openJitSaleDetail, + onCloseJitSaleDetail = viewModel::closeJitSaleDetail, + onUpdateJitSaleCloseDate = viewModel::updateJitSaleCloseDate, + onUpdateJitSalePriceActual = viewModel::updateJitSalePriceActual, + onCloseJitSale = viewModel::closeJitSale, + onRefreshConsignments = viewModel::refreshConsignments, + onOpenConsignmentDetail = viewModel::openConsignmentDetail, + onCloseConsignmentDetail = viewModel::closeConsignmentDetail, + onSelectConsignmentLine = viewModel::selectConsignmentLine, + onUpdateConsignmentCloseDate = viewModel::updateConsignmentCloseDate, + onUpdateConsignmentSellingPrice = viewModel::updateConsignmentSellingPrice, + onUpdateConsignmentQtySold = viewModel::updateConsignmentQtySold, + onUpdateConsignmentQtyReturned = viewModel::updateConsignmentQtyReturned, + onUpdateConsignmentSalesCommission = viewModel::updateConsignmentSalesCommission, + onCloseConsignmentLine = viewModel::closeConsignmentLine, + onRefreshFundRequests = viewModel::refreshFundRequests, + onUpdateFundRequestTransferType = viewModel::updateFundRequestTransferType, + onUpdateFundRequestReferenceNo = viewModel::updateFundRequestReferenceNo, + onSelectFundRequestAgent = viewModel::selectFundRequestAgent, + onSelectFundRequestAgentBank = viewModel::selectFundRequestAgentBank, + onSelectFundRequestCompanyBank = viewModel::selectFundRequestCompanyBank, + onUpdateFundRequestAmount = viewModel::updateFundRequestAmount, + onUpdateFundRequestTransferredAt = viewModel::updateFundRequestTransferredAt, + onSaveFundRequest = viewModel::saveFundRequest, + onRefreshPurchases = viewModel::refreshPurchases, + onOpenCreatePurchase = viewModel::openCreatePurchase, + onOpenPurchaseDetail = viewModel::openPurchaseDetail, + onClosePurchaseDetail = viewModel::closePurchaseDetail, + onOpenEditPurchase = viewModel::openEditPurchase, + onClosePurchaseEditor = viewModel::closePurchaseEditor, + onUpdatePurchaseDate = viewModel::updatePurchaseDate, + onUpdatePurchaseReceivedAt = viewModel::updatePurchaseReceivedAt, + onSelectPurchaseEmployee = viewModel::selectPurchaseEmployee, + onSelectPurchaseWarehouse = viewModel::selectPurchaseWarehouse, + onSelectPurchaseWarehouseLocation = viewModel::selectPurchaseWarehouseLocation, + onUpdatePurchaseNotes = viewModel::updatePurchaseNotes, + onAddPurchaseLine = viewModel::addPurchaseLine, + onRemovePurchaseLine = viewModel::removePurchaseLine, + onSelectPurchaseLineGrade = viewModel::selectPurchaseLineGrade, + onUpdatePurchaseLineQty = viewModel::updatePurchaseLineQty, + onSelectPurchaseLineUnit = viewModel::selectPurchaseLineUnit, + onUpdatePurchaseLineUnitPrice = viewModel::updatePurchaseLineUnitPrice, + onUpdatePurchaseLineUnitCost = viewModel::updatePurchaseLineUnitCost, + onUpdatePurchaseLineNotes = viewModel::updatePurchaseLineNotes, + onSavePurchase = viewModel::savePurchase, + onSubmitPurchase = viewModel::submitPurchase, + onCancelPurchase = viewModel::cancelPurchase, + onRefreshPurchaseAnalyses = viewModel::refreshPurchaseAnalyses, + onOpenPurchaseAnalysisDetail = viewModel::openPurchaseAnalysisDetail, + onClosePurchaseAnalysisDetail = viewModel::closePurchaseAnalysisDetail, + onRefreshPurchaseRealizations = viewModel::refreshPurchaseRealizations, + onOpenPurchaseRealizationDetail = viewModel::openPurchaseRealizationDetail, + onClosePurchaseRealizationDetail = viewModel::closePurchaseRealizationDetail, + onRefreshLotTransformations = viewModel::refreshLotTransformations, + onOpenCreateLotTransformation = viewModel::openCreateLotTransformation, + onOpenLotTransformationDetail = viewModel::openLotTransformationDetail, + onCloseLotTransformationScreen = viewModel::closeLotTransformationScreen, + onUpdateLotTransformationType = viewModel::updateLotTransformationType, + onUpdateLotTransformationDate = viewModel::updateLotTransformationDate, + onUpdateLotTransformationRemainderMode = viewModel::updateLotTransformationRemainderMode, + onUpdateLotTransformationProcessingLossMode = viewModel::updateLotTransformationProcessingLossMode, + onUpdateLotTransformationNotes = viewModel::updateLotTransformationNotes, + onAddLotTransformationInput = viewModel::addLotTransformationInput, + onRemoveLotTransformationInput = viewModel::removeLotTransformationInput, + onUpdateLotTransformationInputQuery = viewModel::updateLotTransformationInputQuery, + onSelectLotTransformationInputLot = viewModel::selectLotTransformationInputLot, + onClearLotTransformationInputLot = viewModel::clearLotTransformationInputLot, + onUpdateLotTransformationInputQty = viewModel::updateLotTransformationInputQty, + onUpdateLotTransformationInputNotes = viewModel::updateLotTransformationInputNotes, + onAddLotTransformationOutput = viewModel::addLotTransformationOutput, + onRemoveLotTransformationOutput = viewModel::removeLotTransformationOutput, + onSelectLotTransformationOutputGrade = viewModel::selectLotTransformationOutputGrade, + onSelectLotTransformationOutputWarehouse = viewModel::selectLotTransformationOutputWarehouse, + onSelectLotTransformationOutputLocation = viewModel::selectLotTransformationOutputLocation, + onUpdateLotTransformationOutputQty = viewModel::updateLotTransformationOutputQty, + onUpdateLotTransformationOutputNotes = viewModel::updateLotTransformationOutputNotes, + onSaveLotTransformation = viewModel::saveLotTransformation, + onRefreshStockAdjustments = viewModel::refreshStockAdjustments, + onSelectStockAdjustmentLot = viewModel::selectStockAdjustmentLot, + onSelectStockAdjustmentReason = viewModel::selectStockAdjustmentReason, + onUpdateStockAdjustmentDate = viewModel::updateStockAdjustmentDate, + onUpdateStockAdjustmentQty = viewModel::updateStockAdjustmentQty, + onUpdateStockAdjustmentNotes = viewModel::updateStockAdjustmentNotes, + onSaveStockAdjustment = viewModel::saveStockAdjustment, + onRefreshWashing = viewModel::refreshWashing, + onOpenCreateWashing = viewModel::openCreateWashing, + onOpenEditWashing = viewModel::openEditWashing, + onOpenCompleteWashing = viewModel::openCompleteWashing, + onCloseWashingScreen = viewModel::closeWashingScreen, + onSelectWashingLot = viewModel::selectWashingLot, + onSelectWashingPlace = viewModel::selectWashingPlace, + onUpdateWashingCost = viewModel::updateWashingCost, + onUpdateWashingDuration = viewModel::updateWashingDuration, + onUpdateAfterQty = viewModel::updateAfterQty, + onSelectCompleteGrade = viewModel::selectCompleteGrade, + onSelectCompleteWarehouse = viewModel::selectCompleteWarehouse, + onSelectCompleteWarehouseLocation = viewModel::selectCompleteWarehouseLocation, + onSaveWashing = viewModel::saveWashing, + onCompleteWashing = viewModel::completeWashing, + onRefreshReceipts = viewModel::refreshReceipts, + onOpenCreateReceipt = viewModel::openCreateReceipt, + onCloseReceiptScreen = viewModel::closeReceiptScreen, + onSelectReceiptPurchase = viewModel::selectReceiptPurchase, + onUpdateReceiptDate = viewModel::updateReceiptDate, + onUpdateReceiptNotes = viewModel::updateReceiptNotes, + onUpdateReceiptLineQtyReceived = viewModel::updateReceiptLineQtyReceived, + onUpdateReceiptLineQtyAccepted = viewModel::updateReceiptLineQtyAccepted, + onUpdateReceiptLineQtyRejected = viewModel::updateReceiptLineQtyRejected, + onUpdateReceiptLineUnitCost = viewModel::updateReceiptLineUnitCost, + onUpdateReceiptLineWarehouse = viewModel::updateReceiptLineWarehouse, + onUpdateReceiptLineLocation = viewModel::updateReceiptLineLocation, + onUpdateReceiptLineNotes = viewModel::updateReceiptLineNotes, + onOpenReceiptDetail = viewModel::openReceiptDetail, + onSaveReceipt = viewModel::saveReceipt, + onGenerateReceiptLots = viewModel::generateReceiptLots, + ) + + else -> LoginScreen( + identity = viewModel.identity, + password = viewModel.password, + isSubmitting = state.isSubmitting, + onIdentityChanged = viewModel::onIdentityChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onSubmit = viewModel::login, + ) + } + } + } +} + +@Composable +private fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(16.dp)) + Text("Memuat data operasional...", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun LoginScreen( + identity: String, + password: String, + isSubmitting: Boolean, + onIdentityChanged: (String) -> Unit, + onPasswordChanged: (String) -> Unit, + onSubmit: () -> Unit, +) { + var showPassword by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.logo_abelbirdnest), + contentDescription = "Logo Abelbirdnest", + modifier = Modifier.size(width = 170.dp, height = 130.dp), + contentScale = ContentScale.Fit, + ) + Text( + text = "AbelBirdnest", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Selamat Datang Kembali", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Silakan masuk untuk mengelola inventaris walet Anda", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + Spacer(modifier = Modifier.height(28.dp)) + OutlinedTextField( + value = identity, + onValueChange = onIdentityChanged, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Outlined.Person, contentDescription = null) }, + label = { Text("Email / Nomor HP / Username") }, + placeholder = { Text("contoh@email.com") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + ) + Spacer(modifier = Modifier.height(14.dp)) + OutlinedTextField( + value = password, + onValueChange = onPasswordChanged, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Outlined.Lock, contentDescription = null) }, + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = null, + ) + } + }, + label = { Text("Kata Sandi") }, + placeholder = { Text("Masukkan kata sandi") }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "Lupa Kata Sandi?", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + Spacer(modifier = Modifier.height(18.dp)) + Button( + onClick = onSubmit, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(14.dp), + enabled = !isSubmitting, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + strokeWidth = 2.dp, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Masuk", fontWeight = FontWeight.SemiBold) + Icon(Icons.Outlined.Login, contentDescription = null) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShellScreen( + data: DashboardBundle, + currentModule: String, + isRefreshing: Boolean, + inlineError: String?, + lotsState: LotsUiState, + salesRegularState: SalesRegularUiState, + salesJitState: SalesJitUiState, + consignmentsState: ConsignmentsUiState, + fundRequestsState: FundRequestsUiState, + purchasesState: PurchasesUiState, + purchaseAnalysesState: PurchaseAnalysesUiState, + purchaseRealizationsState: PurchaseRealizationsUiState, + lotTransformationsState: LotTransformationsUiState, + stockAdjustmentsState: StockAdjustmentsUiState, + washingState: WashingUiState, + receiptsState: ReceiptsUiState, + onRefresh: () -> Unit, + onLogout: () -> Unit, + onModuleSelected: (String) -> Unit, + onRefreshLots: () -> Unit, + onLotQueryChanged: (String) -> Unit, + onLotScanInputChanged: (String) -> Unit, + onScanLot: () -> Unit, + onScanLotCode: (String) -> Unit, + onOpenLotDetail: (String) -> Unit, + onCloseLotDetail: () -> Unit, + onOpenRecentScan: (LotScanResult) -> Unit, + onRefreshRegularSales: () -> Unit, + onOpenRegularSaleDetail: (String) -> Unit, + onCloseRegularSaleDetail: () -> Unit, + onUpdateRegularSaleCloseDate: (String) -> Unit, + onUpdateRegularSaleQtySold: (Int, String) -> Unit, + onUpdateRegularSaleQtyReturned: (Int, String) -> Unit, + onUpdateRegularSalePriceActual: (Int, String) -> Unit, + onCloseRegularSale: () -> Unit, + onRefreshJitSales: () -> Unit, + onOpenJitSaleDetail: (String) -> Unit, + onCloseJitSaleDetail: () -> Unit, + onUpdateJitSaleCloseDate: (String) -> Unit, + onUpdateJitSalePriceActual: (Int, String) -> Unit, + onCloseJitSale: () -> Unit, + onRefreshConsignments: () -> Unit, + onOpenConsignmentDetail: (String) -> Unit, + onCloseConsignmentDetail: () -> Unit, + onSelectConsignmentLine: (String?) -> Unit, + onUpdateConsignmentCloseDate: (String) -> Unit, + onUpdateConsignmentSellingPrice: (String) -> Unit, + onUpdateConsignmentQtySold: (String) -> Unit, + onUpdateConsignmentQtyReturned: (String) -> Unit, + onUpdateConsignmentSalesCommission: (String) -> Unit, + onCloseConsignmentLine: () -> Unit, + onRefreshFundRequests: () -> Unit, + onUpdateFundRequestTransferType: (String) -> Unit, + onUpdateFundRequestReferenceNo: (String) -> Unit, + onSelectFundRequestAgent: (String) -> Unit, + onSelectFundRequestAgentBank: (String) -> Unit, + onSelectFundRequestCompanyBank: (String) -> Unit, + onUpdateFundRequestAmount: (String) -> Unit, + onUpdateFundRequestTransferredAt: (String) -> Unit, + onSaveFundRequest: () -> Unit, + onRefreshPurchases: () -> Unit, + onOpenCreatePurchase: () -> Unit, + onOpenPurchaseDetail: (String) -> Unit, + onClosePurchaseDetail: () -> Unit, + onOpenEditPurchase: () -> Unit, + onClosePurchaseEditor: () -> Unit, + onUpdatePurchaseDate: (String) -> Unit, + onUpdatePurchaseReceivedAt: (String) -> Unit, + onSelectPurchaseEmployee: (String) -> Unit, + onSelectPurchaseWarehouse: (String) -> Unit, + onSelectPurchaseWarehouseLocation: (String?) -> Unit, + onUpdatePurchaseNotes: (String) -> Unit, + onAddPurchaseLine: () -> Unit, + onRemovePurchaseLine: (Int) -> Unit, + onSelectPurchaseLineGrade: (Int, String?) -> Unit, + onUpdatePurchaseLineQty: (Int, String) -> Unit, + onSelectPurchaseLineUnit: (Int, String) -> Unit, + onUpdatePurchaseLineUnitPrice: (Int, String) -> Unit, + onUpdatePurchaseLineUnitCost: (Int, String) -> Unit, + onUpdatePurchaseLineNotes: (Int, String) -> Unit, + onSavePurchase: () -> Unit, + onSubmitPurchase: () -> Unit, + onCancelPurchase: () -> Unit, + onRefreshPurchaseAnalyses: () -> Unit, + onOpenPurchaseAnalysisDetail: (String) -> Unit, + onClosePurchaseAnalysisDetail: () -> Unit, + onRefreshPurchaseRealizations: () -> Unit, + onOpenPurchaseRealizationDetail: (String) -> Unit, + onClosePurchaseRealizationDetail: () -> Unit, + onRefreshLotTransformations: () -> Unit, + onOpenCreateLotTransformation: () -> Unit, + onOpenLotTransformationDetail: (String) -> Unit, + onCloseLotTransformationScreen: () -> Unit, + onUpdateLotTransformationType: (String) -> Unit, + onUpdateLotTransformationDate: (String) -> Unit, + onUpdateLotTransformationRemainderMode: (String?) -> Unit, + onUpdateLotTransformationProcessingLossMode: (String?) -> Unit, + onUpdateLotTransformationNotes: (String) -> Unit, + onAddLotTransformationInput: () -> Unit, + onRemoveLotTransformationInput: (Int) -> Unit, + onUpdateLotTransformationInputQuery: (Int, String) -> Unit, + onSelectLotTransformationInputLot: (Int, String) -> Unit, + onClearLotTransformationInputLot: (Int) -> Unit, + onUpdateLotTransformationInputQty: (Int, String) -> Unit, + onUpdateLotTransformationInputNotes: (Int, String) -> Unit, + onAddLotTransformationOutput: () -> Unit, + onRemoveLotTransformationOutput: (Int) -> Unit, + onSelectLotTransformationOutputGrade: (Int, String) -> Unit, + onSelectLotTransformationOutputWarehouse: (Int, String) -> Unit, + onSelectLotTransformationOutputLocation: (Int, String?) -> Unit, + onUpdateLotTransformationOutputQty: (Int, String) -> Unit, + onUpdateLotTransformationOutputNotes: (Int, String) -> Unit, + onSaveLotTransformation: () -> Unit, + onRefreshStockAdjustments: () -> Unit, + onSelectStockAdjustmentLot: (String) -> Unit, + onSelectStockAdjustmentReason: (String) -> Unit, + onUpdateStockAdjustmentDate: (String) -> Unit, + onUpdateStockAdjustmentQty: (String) -> Unit, + onUpdateStockAdjustmentNotes: (String) -> Unit, + onSaveStockAdjustment: () -> Unit, + onRefreshWashing: () -> Unit, + onOpenCreateWashing: () -> Unit, + onOpenEditWashing: (String) -> Unit, + onOpenCompleteWashing: (String) -> Unit, + onCloseWashingScreen: () -> Unit, + onSelectWashingLot: (String) -> Unit, + onSelectWashingPlace: (String) -> Unit, + onUpdateWashingCost: (String) -> Unit, + onUpdateWashingDuration: (String) -> Unit, + onUpdateAfterQty: (String) -> Unit, + onSelectCompleteGrade: (String?) -> Unit, + onSelectCompleteWarehouse: (String) -> Unit, + onSelectCompleteWarehouseLocation: (String?) -> Unit, + onSaveWashing: () -> Unit, + onCompleteWashing: () -> Unit, + onRefreshReceipts: () -> Unit, + onOpenCreateReceipt: () -> Unit, + onCloseReceiptScreen: () -> Unit, + onSelectReceiptPurchase: (String) -> Unit, + onUpdateReceiptDate: (String) -> Unit, + onUpdateReceiptNotes: (String) -> Unit, + onUpdateReceiptLineQtyReceived: (Int, String) -> Unit, + onUpdateReceiptLineQtyAccepted: (Int, String) -> Unit, + onUpdateReceiptLineQtyRejected: (Int, String) -> Unit, + onUpdateReceiptLineUnitCost: (Int, String) -> Unit, + onUpdateReceiptLineWarehouse: (Int, String) -> Unit, + onUpdateReceiptLineLocation: (Int, String) -> Unit, + onUpdateReceiptLineNotes: (Int, String) -> Unit, + onOpenReceiptDetail: (String) -> Unit, + onSaveReceipt: () -> Unit, + onGenerateReceiptLots: () -> Unit, +) { + val quickActions = remember(data.user.role, data.modules) { + buildQuickActions(role = data.user.role, modules = data.modules) + } + val bottomItems = remember(currentModule, data.modules, data.user.role) { + buildBottomBarItems(role = data.user.role, currentModule = currentModule, modules = data.modules) + } + + Scaffold( + topBar = { + Surface(shadowElevation = 2.dp, color = MaterialTheme.colorScheme.surface) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Text( + text = data.user.name.split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString(""), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Column { + Text( + text = "Abelbirdnest Stock", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${data.user.role} • ${moduleLabel(currentModule)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Row { + IconButton(onClick = onRefresh) { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh") + } + IconButton(onClick = onLogout) { + Icon(Icons.Outlined.ExitToApp, contentDescription = "Logout") + } + } + } + if (isRefreshing) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) + } + } + } + }, + bottomBar = { + BottomBar( + items = bottomItems, + currentModule = currentModule, + onModuleSelected = onModuleSelected, + ) + }, + ) { innerPadding -> + when (currentModule) { + "dashboard" -> DashboardScreen( + data = data, + quickActions = quickActions, + bottomItems = bottomItems, + inlineError = inlineError, + onModuleSelected = onModuleSelected, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) + + "lots" -> LotsModuleScreen( + role = data.user.role, + state = lotsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshLots, + onQueryChanged = onLotQueryChanged, + onScanInputChanged = onLotScanInputChanged, + onScan = onScanLot, + onCameraScan = onScanLotCode, + onOpenLotDetail = onOpenLotDetail, + onCloseLotDetail = onCloseLotDetail, + onOpenRecentScan = onOpenRecentScan, + ) + + "sales_regular" -> RegularSalesModuleScreen( + role = data.user.role, + state = salesRegularState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshRegularSales, + onOpenDetail = onOpenRegularSaleDetail, + onBack = onCloseRegularSaleDetail, + onUpdateCloseDate = onUpdateRegularSaleCloseDate, + onUpdateQtySold = onUpdateRegularSaleQtySold, + onUpdateQtyReturned = onUpdateRegularSaleQtyReturned, + onUpdatePriceActual = onUpdateRegularSalePriceActual, + onCloseSale = onCloseRegularSale, + ) + + "sales_jit" -> JitSalesModuleScreen( + role = data.user.role, + state = salesJitState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshJitSales, + onOpenDetail = onOpenJitSaleDetail, + onBack = onCloseJitSaleDetail, + onUpdateCloseDate = onUpdateJitSaleCloseDate, + onUpdatePriceActual = onUpdateJitSalePriceActual, + onCloseSale = onCloseJitSale, + ) + + "consignments" -> ConsignmentsModuleScreen( + role = data.user.role, + state = consignmentsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshConsignments, + onOpenDetail = onOpenConsignmentDetail, + onBack = onCloseConsignmentDetail, + onSelectLine = onSelectConsignmentLine, + onUpdateCloseDate = onUpdateConsignmentCloseDate, + onUpdateSellingPrice = onUpdateConsignmentSellingPrice, + onUpdateQtySold = onUpdateConsignmentQtySold, + onUpdateQtyReturned = onUpdateConsignmentQtyReturned, + onUpdateSalesCommission = onUpdateConsignmentSalesCommission, + onCloseLine = onCloseConsignmentLine, + ) + + "fund_requests" -> FundRequestsModuleScreen( + state = fundRequestsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshFundRequests, + onUpdateTransferType = onUpdateFundRequestTransferType, + onUpdateReferenceNo = onUpdateFundRequestReferenceNo, + onSelectAgent = onSelectFundRequestAgent, + onSelectAgentBank = onSelectFundRequestAgentBank, + onSelectCompanyBank = onSelectFundRequestCompanyBank, + onUpdateAmount = onUpdateFundRequestAmount, + onUpdateTransferredAt = onUpdateFundRequestTransferredAt, + onSave = onSaveFundRequest, + ) + + "purchases" -> PurchasesModuleScreen( + role = data.user.role, + state = purchasesState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshPurchases, + onOpenCreate = onOpenCreatePurchase, + onOpenDetail = onOpenPurchaseDetail, + onBack = onClosePurchaseDetail, + onOpenEdit = onOpenEditPurchase, + onCloseEditor = onClosePurchaseEditor, + onUpdatePurchaseDate = onUpdatePurchaseDate, + onUpdateReceivedAt = onUpdatePurchaseReceivedAt, + onSelectEmployee = onSelectPurchaseEmployee, + onSelectWarehouse = onSelectPurchaseWarehouse, + onSelectWarehouseLocation = onSelectPurchaseWarehouseLocation, + onUpdateNotes = onUpdatePurchaseNotes, + onAddLine = onAddPurchaseLine, + onRemoveLine = onRemovePurchaseLine, + onSelectLineGrade = onSelectPurchaseLineGrade, + onUpdateLineQty = onUpdatePurchaseLineQty, + onSelectLineUnit = onSelectPurchaseLineUnit, + onUpdateLineUnitPrice = onUpdatePurchaseLineUnitPrice, + onUpdateLineUnitCost = onUpdatePurchaseLineUnitCost, + onUpdateLineNotes = onUpdatePurchaseLineNotes, + onSave = onSavePurchase, + onSubmit = onSubmitPurchase, + onCancel = onCancelPurchase, + ) + + "purchase_analyses" -> PurchaseAnalysesModuleScreen( + state = purchaseAnalysesState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshPurchaseAnalyses, + onOpenDetail = onOpenPurchaseAnalysisDetail, + onBack = onClosePurchaseAnalysisDetail, + ) + + "purchase_realizations" -> PurchaseRealizationsModuleScreen( + state = purchaseRealizationsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshPurchaseRealizations, + onOpenDetail = onOpenPurchaseRealizationDetail, + onBack = onClosePurchaseRealizationDetail, + ) + + "lot_transformations" -> LotTransformationsModuleScreen( + state = lotTransformationsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshLotTransformations, + onOpenCreate = onOpenCreateLotTransformation, + onOpenDetail = onOpenLotTransformationDetail, + onBack = onCloseLotTransformationScreen, + onUpdateType = onUpdateLotTransformationType, + onUpdateDate = onUpdateLotTransformationDate, + onUpdateRemainderMode = onUpdateLotTransformationRemainderMode, + onUpdateProcessingLossMode = onUpdateLotTransformationProcessingLossMode, + onUpdateNotes = onUpdateLotTransformationNotes, + onAddInput = onAddLotTransformationInput, + onRemoveInput = onRemoveLotTransformationInput, + onUpdateInputQuery = onUpdateLotTransformationInputQuery, + onSelectInputLot = onSelectLotTransformationInputLot, + onClearInputLot = onClearLotTransformationInputLot, + onUpdateInputQty = onUpdateLotTransformationInputQty, + onUpdateInputNotes = onUpdateLotTransformationInputNotes, + onAddOutput = onAddLotTransformationOutput, + onRemoveOutput = onRemoveLotTransformationOutput, + onSelectOutputGrade = onSelectLotTransformationOutputGrade, + onSelectOutputWarehouse = onSelectLotTransformationOutputWarehouse, + onSelectOutputLocation = onSelectLotTransformationOutputLocation, + onUpdateOutputQty = onUpdateLotTransformationOutputQty, + onUpdateOutputNotes = onUpdateLotTransformationOutputNotes, + onSave = onSaveLotTransformation, + ) + + "stock_adjustments" -> StockAdjustmentsModuleScreen( + state = stockAdjustmentsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshStockAdjustments, + onSelectLot = onSelectStockAdjustmentLot, + onSelectReason = onSelectStockAdjustmentReason, + onUpdateDate = onUpdateStockAdjustmentDate, + onUpdateQty = onUpdateStockAdjustmentQty, + onUpdateNotes = onUpdateStockAdjustmentNotes, + onSave = onSaveStockAdjustment, + ) + + "washing" -> WashingModuleScreen( + role = data.user.role, + state = washingState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshWashing, + onOpenCreate = onOpenCreateWashing, + onOpenEdit = onOpenEditWashing, + onOpenComplete = onOpenCompleteWashing, + onBack = onCloseWashingScreen, + onSelectLot = onSelectWashingLot, + onSelectPlace = onSelectWashingPlace, + onUpdateCost = onUpdateWashingCost, + onUpdateDuration = onUpdateWashingDuration, + onUpdateAfterQty = onUpdateAfterQty, + onSelectGrade = onSelectCompleteGrade, + onSelectWarehouse = onSelectCompleteWarehouse, + onSelectWarehouseLocation = onSelectCompleteWarehouseLocation, + onSave = onSaveWashing, + onComplete = onCompleteWashing, + ) + + "receipts" -> ReceiptsModuleScreen( + state = receiptsState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + onRefresh = onRefreshReceipts, + onOpenCreate = onOpenCreateReceipt, + onBack = onCloseReceiptScreen, + onSelectPurchase = onSelectReceiptPurchase, + onUpdateReceiptDate = onUpdateReceiptDate, + onUpdateReceiptNotes = onUpdateReceiptNotes, + onUpdateQtyReceived = onUpdateReceiptLineQtyReceived, + onUpdateQtyAccepted = onUpdateReceiptLineQtyAccepted, + onUpdateQtyRejected = onUpdateReceiptLineQtyRejected, + onUpdateUnitCost = onUpdateReceiptLineUnitCost, + onUpdateWarehouse = onUpdateReceiptLineWarehouse, + onUpdateLocation = onUpdateReceiptLineLocation, + onUpdateLineNotes = onUpdateReceiptLineNotes, + onOpenDetail = onOpenReceiptDetail, + onSave = onSaveReceipt, + onGenerateLots = onGenerateReceiptLots, + ) + + else -> ModulePlaceholderScreen( + module = currentModule, + role = data.user.role, + availableModules = data.modules, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) + } + } +} + +@Composable +private fun DashboardScreen( + data: DashboardBundle, + quickActions: List, + bottomItems: List, + inlineError: String?, + onModuleSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val bottomModules = bottomItems.filter { it != "dashboard" }.distinct() + val extraQuickActions = quickActions.filter { it.module !in bottomModules }.distinctBy { it.module } + + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Halo, ${data.user.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Update terkini inventaris dan operasional untuk role ${data.user.role.lowercase().replaceFirstChar { it.uppercase() }}.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (!inlineError.isNullOrBlank()) { + item { + InlineErrorCard(message = inlineError) + } + } + + item { + MetricSection(data = data) + } + + if (extraQuickActions.isNotEmpty()) { + item { + QuickActionSection( + actions = extraQuickActions, + onActionClick = { onModuleSelected(it.module) }, + ) + } + } + + item { + AlertSection(criticalLots = data.criticalLots) + } + + item { + DistributionSection(items = data.gradeDistribution) + } + + item { + Spacer(modifier = Modifier.height(6.dp)) + Image( + painter = painterResource(R.drawable.logo_abelbirdnest), + contentDescription = null, + modifier = Modifier + .size(width = 120.dp, height = 44.dp) + .padding(bottom = 4.dp), + alpha = 0.48f, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun MetricSection(data: DashboardBundle) { + val showPurchaseValue = data.user.role in setOf("OWNER", "PURCHASING") + val cards = buildList { + addAll( + data.metrics + .filterNot { metric -> + !showPurchaseValue && metric.label.contains("pembelian", ignoreCase = true) + } + .take(3), + ) + if (none { it.label.contains("Lot", ignoreCase = true) }) { + add( + id.abelbirdnest.mobile.data.DashboardMetric( + label = "Lot Aktif", + value = data.summary.activeLotCount.toString(), + delta = "Unit produksi aktif", + ), + ) + } + }.take(3) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + cards.forEachIndexed { index, metric -> + MetricCard( + modifier = if (index == 0) Modifier.fillMaxWidth() else Modifier.fillMaxWidth(), + label = metric.label, + value = metric.value, + delta = metric.delta, + highlighted = index == 0, + ) + } + } +} + +@Composable +private fun MetricCard( + modifier: Modifier = Modifier, + label: String, + value: String, + delta: String, + highlighted: Boolean, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = if (highlighted) 2.dp else 0.dp, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(label.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = value, + style = if (highlighted) MaterialTheme.typography.displaySmall else MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Text(delta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun QuickActionSection( + actions: List, + onActionClick: (QuickAction) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Aksi Cepat", style = MaterialTheme.typography.titleMedium) + LazyVerticalGrid( + columns = GridCells.Fixed(4), + modifier = Modifier.height(((actions.chunked(4).size.coerceAtLeast(1)) * 104).dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + userScrollEnabled = false, + ) { + gridItems(actions) { action -> + Column( + modifier = Modifier.clickable { onActionClick(action) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = if (action == actions.first()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = action.icon(), + contentDescription = null, + tint = if (action == actions.first()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + } + } + Text( + text = action.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun AlertSection(criticalLots: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Peringatan Lot", style = MaterialTheme.typography.titleMedium) + AssistChip( + onClick = {}, + label = { + Text("${criticalLots.size} perlu tindakan") + }, + ) + } + + if (criticalLots.isEmpty()) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Text( + text = "Tidak ada lot kritis saat ini.", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + criticalLots.take(3).forEach { lot -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(alertColor(lot.attentionStatus).copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Inventory2, contentDescription = null, tint = alertColor(lot.attentionStatus)) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${lot.lotCode} • ${lot.item}", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Text(lot.reason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = lot.availableQty, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } +} + +@Composable +private fun DistributionSection(items: List) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text("Distribusi Stok Grade", style = MaterialTheme.typography.titleMedium) + if (items.isEmpty()) { + Text( + text = "Belum ada distribusi grade yang bisa dihitung.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + items.forEachIndexed { index, item -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(item.grade, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + Text( + "${formatQuantity(item.quantity)} kg (${item.percentage}%)", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Box( + modifier = Modifier + .fillMaxWidth(item.percentage.coerceIn(3, 100) / 100f) + .height(8.dp) + .clip(CircleShape) + .background( + if (index == 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primary.copy(alpha = 0.62f), + ), + ) + } + } + } + } + } + } +} + +@Composable +private fun BottomBar( + items: List, + currentModule: String, + onModuleSelected: (String) -> Unit, +) { + Surface(shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEach { module -> + val active = module == currentModule + Surface( + color = if (active) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + shape = RoundedCornerShape(999.dp), + modifier = Modifier.clip(RoundedCornerShape(999.dp)).clickable { onModuleSelected(module) }, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = moduleIcon(module), + contentDescription = null, + tint = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = moduleLabel(module), + style = MaterialTheme.typography.labelSmall, + color = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun LotsModuleScreen( + role: String, + state: LotsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onQueryChanged: (String) -> Unit, + onScanInputChanged: (String) -> Unit, + onScan: () -> Unit, + onCameraScan: (String) -> Unit, + onOpenLotDetail: (String) -> Unit, + onCloseLotDetail: () -> Unit, + onOpenRecentScan: (LotScanResult) -> Unit, +) { + var activeTab by remember { mutableStateOf("inventory") } + + when { + state.selectedLotId != null -> { + LotDetailScreen( + detail = state.lotDetail, + isLoading = state.isLoadingDetail, + inlineError = state.inlineError, + modifier = modifier, + onBack = onCloseLotDetail, + ) + } + + activeTab == "scan" -> { + LotScanScreen( + role = role, + state = state, + modifier = modifier, + onTabChanged = { activeTab = it }, + onScanInputChanged = onScanInputChanged, + onScan = onScan, + onCameraScan = onCameraScan, + onOpenRecentScan = onOpenRecentScan, + ) + } + + else -> { + LotInventoryScreen( + state = state, + modifier = modifier, + onTabChanged = { activeTab = it }, + onRefresh = onRefresh, + onQueryChanged = onQueryChanged, + onOpenLotDetail = onOpenLotDetail, + ) + } + } +} + +@Composable +private fun WashingModuleScreen( + role: String, + state: WashingUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenEdit: (String) -> Unit, + onOpenComplete: (String) -> Unit, + onBack: () -> Unit, + onSelectLot: (String) -> Unit, + onSelectPlace: (String) -> Unit, + onUpdateCost: (String) -> Unit, + onUpdateDuration: (String) -> Unit, + onUpdateAfterQty: (String) -> Unit, + onSelectGrade: (String?) -> Unit, + onSelectWarehouse: (String) -> Unit, + onSelectWarehouseLocation: (String?) -> Unit, + onSave: () -> Unit, + onComplete: () -> Unit, +) { + when (state.screen) { + WashingScreen.List -> WashingListScreen( + role = role, + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenCreate = onOpenCreate, + onOpenEdit = onOpenEdit, + onOpenComplete = onOpenComplete, + ) + + WashingScreen.Create, + WashingScreen.Edit, + -> WashingFormScreen( + state = state, + modifier = modifier, + onBack = onBack, + onSelectLot = onSelectLot, + onSelectPlace = onSelectPlace, + onUpdateCost = onUpdateCost, + onUpdateDuration = onUpdateDuration, + onSave = onSave, + ) + + WashingScreen.Complete -> WashingCompleteScreen( + state = state, + modifier = modifier, + onBack = onBack, + onUpdateAfterQty = onUpdateAfterQty, + onSelectGrade = onSelectGrade, + onSelectWarehouse = onSelectWarehouse, + onSelectWarehouseLocation = onSelectWarehouseLocation, + onComplete = onComplete, + ) + } +} + +@Composable +private fun ReceiptsModuleScreen( + state: ReceiptsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onBack: () -> Unit, + onSelectPurchase: (String) -> Unit, + onUpdateReceiptDate: (String) -> Unit, + onUpdateReceiptNotes: (String) -> Unit, + onUpdateQtyReceived: (Int, String) -> Unit, + onUpdateQtyAccepted: (Int, String) -> Unit, + onUpdateQtyRejected: (Int, String) -> Unit, + onUpdateUnitCost: (Int, String) -> Unit, + onUpdateWarehouse: (Int, String) -> Unit, + onUpdateLocation: (Int, String) -> Unit, + onUpdateLineNotes: (Int, String) -> Unit, + onOpenDetail: (String) -> Unit, + onSave: () -> Unit, + onGenerateLots: () -> Unit, +) { + when (state.screen) { + ReceiptsScreen.List -> ReceiptsListScreen( + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenCreate = onOpenCreate, + onOpenDetail = onOpenDetail, + ) + + ReceiptsScreen.Create -> ReceiptCreateScreen( + state = state, + modifier = modifier, + onBack = onBack, + onSelectPurchase = onSelectPurchase, + onUpdateReceiptDate = onUpdateReceiptDate, + onUpdateReceiptNotes = onUpdateReceiptNotes, + onUpdateQtyReceived = onUpdateQtyReceived, + onUpdateQtyAccepted = onUpdateQtyAccepted, + onUpdateQtyRejected = onUpdateQtyRejected, + onUpdateUnitCost = onUpdateUnitCost, + onUpdateWarehouse = onUpdateWarehouse, + onUpdateLocation = onUpdateLocation, + onUpdateLineNotes = onUpdateLineNotes, + onSave = onSave, + ) + + ReceiptsScreen.Detail -> ReceiptDetailScreen( + state = state, + modifier = modifier, + onBack = onBack, + onGenerateLots = onGenerateLots, + ) + } +} + +@Composable +private fun ReceiptsListScreen( + state: ReceiptsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Receipts", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Buat penerimaan dari purchase yang sudah submitted lalu generate lot hasil terima.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button( + onClick = onOpenCreate, + shape = RoundedCornerShape(16.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Baru") + } + } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Ringkasan Receipt", style = MaterialTheme.typography.titleMedium) + Text( + "${state.receipts.size} receipt • ${state.purchases.size} purchase siap diterima", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh receipts") + } + } + } + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.receipts.isEmpty()) { + item { LoadingCard("Memuat receipt...") } + } else if (state.receipts.isEmpty()) { + item { EmptyStateCard("Belum ada receipt. Buat receipt pertama dari purchase yang sudah submitted.") } + } else { + items(state.receipts, key = { it.id }) { item -> + ReceiptListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun ReceiptCreateScreen( + state: ReceiptsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onSelectPurchase: (String) -> Unit, + onUpdateReceiptDate: (String) -> Unit, + onUpdateReceiptNotes: (String) -> Unit, + onUpdateQtyReceived: (Int, String) -> Unit, + onUpdateQtyAccepted: (Int, String) -> Unit, + onUpdateQtyRejected: (Int, String) -> Unit, + onUpdateUnitCost: (Int, String) -> Unit, + onUpdateWarehouse: (Int, String) -> Unit, + onUpdateLocation: (Int, String) -> Unit, + onUpdateLineNotes: (Int, String) -> Unit, + onSave: () -> Unit, +) { + var purchaseExpanded by remember { mutableStateOf(false) } + val selectedPurchase = remember(state.purchases, state.selectedPurchaseId) { + state.purchases.find { it.id == state.selectedPurchaseId } + } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Buat Receipt", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Purchase", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (state.purchases.isEmpty()) { + EmptyStateCard("Belum ada purchase submitted yang siap dibuat receipt.") + } else { + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { purchaseExpanded = true }, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Pilih Purchase", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + selectedPurchase?.purchaseNo ?: "Tap untuk memilih purchase", + color = if (selectedPurchase != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (selectedPurchase != null) FontWeight.SemiBold else FontWeight.Normal, + ) + selectedPurchase?.supplierName?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + DropdownMenu( + expanded = purchaseExpanded, + onDismissRequest = { purchaseExpanded = false }, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + state.purchases.forEach { purchase -> + DropdownMenuItem( + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(purchase.purchaseNo, fontWeight = FontWeight.SemiBold) + Text( + listOfNotNull(purchase.supplierName, purchase.purchaseDate).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onSelectPurchase(purchase.id) + purchaseExpanded = false + }, + ) + } + } + } + } + OutlinedTextField( + value = state.receiptDate, + onValueChange = onUpdateReceiptDate, + modifier = Modifier.fillMaxWidth(), + label = { Text("Tanggal Receipt") }, + placeholder = { Text("2026-05-20") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + OutlinedTextField( + value = state.notes, + onValueChange = onUpdateReceiptNotes, + modifier = Modifier.fillMaxWidth(), + label = { Text("Catatan") }, + minLines = 3, + shape = RoundedCornerShape(16.dp), + ) + } + } + } + + if (state.lines.isEmpty()) { + item { EmptyStateCard("Pilih purchase untuk memuat line receipt.") } + } else { + items(state.lines.indices.toList(), key = { it }) { index -> + ReceiptLineFormCard( + line = state.lines[index], + warehouses = state.warehouses, + onQtyReceived = { onUpdateQtyReceived(index, it) }, + onQtyAccepted = { onUpdateQtyAccepted(index, it) }, + onQtyRejected = { onUpdateQtyRejected(index, it) }, + onUnitCost = { onUpdateUnitCost(index, it) }, + onWarehouse = { onUpdateWarehouse(index, it) }, + onLocation = { onUpdateLocation(index, it) }, + onNotes = { onUpdateLineNotes(index, it) }, + ) + } + } + + item { + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.Outlined.ReceiptLong, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Simpan Receipt") + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } +} + +@Composable +private fun ReceiptDetailScreen( + state: ReceiptsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onGenerateLots: () -> Unit, +) { + val detail = state.selectedReceipt + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail receipt...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail receipt belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Receipt", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { ReceiptDetailHeader(detail = detail, isGeneratingLots = state.isGeneratingLots, onGenerateLots = onGenerateLots) } + item { ReceiptLinesSection(lines = detail.lines) } + item { ReceiptGeneratedLotsSection(lots = detail.generatedLots) } + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun ReceiptListCard( + item: ReceiptListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("RECEIPT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.receiptNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("PURCHASE", item.purchase.purchaseNo ?: "-") + LotMetaCell("TANGGAL", item.receiptDate, alignEnd = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("LINES", item.lineCount.toString(), emphasize = true) + LotMetaCell("LOTS", item.lotCount.toString(), alignEnd = true, emphasize = true) + } + } + } +} + +@Composable +private fun ReceiptLineFormCard( + line: ReceiptLineFormState, + warehouses: List, + onQtyReceived: (String) -> Unit, + onQtyAccepted: (String) -> Unit, + onQtyRejected: (String) -> Unit, + onUnitCost: (String) -> Unit, + onWarehouse: (String) -> Unit, + onLocation: (String) -> Unit, + onNotes: (String) -> Unit, +) { + var warehouseExpanded by remember { mutableStateOf(false) } + var locationExpanded by remember { mutableStateOf(false) } + val selectedWarehouse = warehouses.find { it.id == line.warehouseId } + val selectedLocation = selectedWarehouse?.locations?.find { it.id == line.warehouseLocationId } + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(line.gradeName, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text("Qty order: ${line.qtyOrdered} ${line.unitCode}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = line.qtyReceived, + onValueChange = onQtyReceived, + modifier = Modifier.weight(1f), + label = { Text("Received") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = line.qtyAccepted, + onValueChange = onQtyAccepted, + modifier = Modifier.weight(1f), + label = { Text("Accepted") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = line.qtyRejected, + onValueChange = onQtyRejected, + modifier = Modifier.weight(1f), + label = { Text("Rejected") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = line.unitCost, + onValueChange = onUnitCost, + modifier = Modifier.weight(1f), + label = { Text("Unit Cost") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(14.dp), + ) + } + + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { warehouseExpanded = true }, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedWarehouse?.name ?: "Pilih gudang", color = if (selectedWarehouse != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + DropdownMenu(expanded = warehouseExpanded, onDismissRequest = { warehouseExpanded = false }, modifier = Modifier.fillMaxWidth(0.92f)) { + warehouses.forEach { warehouse -> + DropdownMenuItem( + text = { Text(warehouse.name) }, + onClick = { + onWarehouse(warehouse.id) + warehouseExpanded = false + }, + ) + } + } + } + + if (!selectedWarehouse?.locations.isNullOrEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { locationExpanded = true }, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedLocation?.name ?: "Pilih lokasi", color = if (selectedLocation != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + DropdownMenu(expanded = locationExpanded, onDismissRequest = { locationExpanded = false }, modifier = Modifier.fillMaxWidth(0.92f)) { + selectedWarehouse?.locations?.forEach { location -> + DropdownMenuItem( + text = { Text(location.name) }, + onClick = { + onLocation(location.id) + locationExpanded = false + }, + ) + } + } + } + } + + OutlinedTextField( + value = line.notes, + onValueChange = onNotes, + modifier = Modifier.fillMaxWidth(), + label = { Text("Catatan Line") }, + minLines = 2, + shape = RoundedCornerShape(14.dp), + ) + } + } +} + +@Composable +private fun ReceiptDetailHeader( + detail: ReceiptDetail, + isGeneratingLots: Boolean, + onGenerateLots: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("RECEIPT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.receiptNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("PURCHASE", detail.purchase.purchaseNo ?: "-") + LotMetaCell("TANGGAL", detail.receiptDate, alignEnd = true) + } + if (!detail.notes.isNullOrBlank()) { + Text(detail.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (detail.status != "FINALIZED") { + Button( + onClick = onGenerateLots, + enabled = !isGeneratingLots, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + if (isGeneratingLots) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.Outlined.Inventory2, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Generate Lots") + } + } + } + } + } +} + +@Composable +private fun ReceiptLinesSection(lines: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Receipt Lines", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lines.isEmpty()) { + EmptyStateCard("Belum ada line receipt.") + } else { + lines.forEach { line -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.grade?.name ?: "-", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("RECEIVED", "${formatQuantity(line.qtyReceived)} ${line.unit.code}", emphasize = true) + LotMetaCell("ACCEPTED", "${formatQuantity(line.qtyAccepted)} ${line.unit.code}", alignEnd = true, emphasize = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("REJECTED", "${formatQuantity(line.qtyRejected)} ${line.unit.code}") + LotMetaCell("UNIT COST", formatCurrency(line.unitCost), alignEnd = true) + } + Text( + listOfNotNull(line.warehouse?.name, line.location?.name).joinToString(" • ").ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun ReceiptGeneratedLotsSection(lots: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Generated Lots", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lots.isEmpty()) { + EmptyStateCard("Belum ada lot yang dihasilkan dari receipt ini.") + } else { + lots.forEach { lot -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(lot.lotCode, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + StatusPill(status = lot.status) + } + } + } + } + } +} + +@Composable +private fun StockAdjustmentsModuleScreen( + state: StockAdjustmentsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onSelectLot: (String) -> Unit, + onSelectReason: (String) -> Unit, + onUpdateDate: (String) -> Unit, + onUpdateQty: (String) -> Unit, + onUpdateNotes: (String) -> Unit, + onSave: () -> Unit, +) { + var lotQuery by remember(state.selectedLotId, state.selectableLots) { + mutableStateOf(state.selectableLots.find { it.id == state.selectedLotId }?.lotCode.orEmpty()) + } + var reasonExpanded by remember { mutableStateOf(false) } + val selectedLot = remember(state.selectableLots, state.selectedLotId) { + state.selectableLots.find { it.id == state.selectedLotId } + } + val selectedReason = remember(state.reasons, state.selectedReasonId) { + state.reasons.find { it.id == state.selectedReasonId } + } + val filteredLots = remember(state.selectableLots, lotQuery) { + val query = lotQuery.trim().lowercase() + if (query.isBlank()) { + emptyList() + } else { + state.selectableLots.filter { + it.lotCode.lowercase().contains(query) || + it.supplier.lowercase().contains(query) || + it.grade.lowercase().contains(query) || + it.warehouse.lowercase().contains(query) || + it.location.lowercase().contains(query) + }.take(8) + } + } + val isSearchingLots = remember(lotQuery, selectedLot?.lotCode) { + val query = lotQuery.trim() + query.isNotBlank() && !query.equals(selectedLot?.lotCode.orEmpty(), ignoreCase = true) + } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Stock Adjustment", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Catat penambahan, shrinkage, atau koreksi stok langsung dari lot aktif.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh stock adjustments") + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.items.size} adjustment") }) + AssistChip(onClick = {}, label = { Text("${state.selectableLots.size} lot aktif") }) + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Buat Adjustment", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = lotQuery, + onValueChange = { lotQuery = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Cari Lot") }, + placeholder = { Text("Kode lot / supplier / grade") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + when { + state.selectableLots.isEmpty() -> EmptyStateCard("Belum ada lot aktif yang bisa di-adjust.") + lotQuery.isBlank() -> EmptyStateCard("Ketik kata kunci untuk mencari lot yang akan di-adjust.") + selectedLot != null && !isSearchingLots -> {} + filteredLots.isEmpty() -> EmptyStateCard("Tidak ada lot yang cocok dengan pencarian.") + else -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + filteredLots.forEach { lot -> + Surface( + shape = RoundedCornerShape(16.dp), + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + 1.dp, + if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { + onSelectLot(lot.id) + lotQuery = lot.lotCode + }, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + lot.lotCode, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${formatQuantity(lot.availableQty)} ${lot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + ) + } + Text( + "${lot.supplier} • ${lot.grade}", + style = MaterialTheme.typography.bodySmall, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + + selectedLot?.let { lot -> + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(lot.lotCode, color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.SemiBold) + Text( + "${lot.supplier} • ${lot.grade} • ${formatQuantity(lot.availableQty)} ${lot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f), + ) + Text( + "${lot.warehouse} • ${lot.location}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f), + ) + } + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { reasonExpanded = true }, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Alasan Adjustment", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + selectedReason?.let { "${it.code} - ${it.name}" } ?: "Pilih alasan adjustment", + color = if (selectedReason != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedReason != null) FontWeight.SemiBold else FontWeight.Normal, + ) + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + DropdownMenu( + expanded = reasonExpanded, + onDismissRequest = { reasonExpanded = false }, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + state.reasons.forEach { reason -> + DropdownMenuItem( + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("${reason.code} - ${reason.name}", fontWeight = FontWeight.SemiBold) + Text(reason.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + onClick = { + onSelectReason(reason.id) + reasonExpanded = false + }, + ) + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.adjustmentDate, + onValueChange = onUpdateDate, + modifier = Modifier.weight(1f), + label = { Text("Tanggal") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + OutlinedTextField( + value = state.qtyChange, + onValueChange = onUpdateQty, + modifier = Modifier.weight(1f), + label = { Text("Qty +/-") }, + placeholder = { Text("-0.500 atau 1.250") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(16.dp), + ) + } + + OutlinedTextField( + value = state.notes, + onValueChange = onUpdateNotes, + modifier = Modifier.fillMaxWidth(), + label = { Text("Catatan") }, + minLines = 3, + shape = RoundedCornerShape(16.dp), + ) + + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.Outlined.ShowChart, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Simpan Adjustment") + } + } + } + } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat riwayat stock adjustment...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada stock adjustment untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + StockAdjustmentCard(item = item) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun RegularSalesModuleScreen( + role: String, + state: SalesRegularUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdateQtySold: (Int, String) -> Unit, + onUpdateQtyReturned: (Int, String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + if (state.selectedSaleId != null || state.selectedSale != null) { + RegularSaleDetailScreen( + role = role, + state = state, + modifier = modifier, + onBack = onBack, + onUpdateCloseDate = onUpdateCloseDate, + onUpdateQtySold = onUpdateQtySold, + onUpdateQtyReturned = onUpdateQtyReturned, + onUpdatePriceActual = onUpdatePriceActual, + onCloseSale = onCloseSale, + ) + } else { + RegularSalesListScreen( + role = role, + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenDetail = onOpenDetail, + ) + } +} + +@Composable +private fun RegularSalesListScreen( + role: String, + state: SalesRegularUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Sales Regular", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + if (role == "SALES") "Pantau penjualan reguler lalu tutup transaksi saat qty aktual dan retur sudah final." + else "Lihat transaksi regular sale yang sedang berjalan beserta total penjualannya.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh sales") + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.items.size} sales") }) + AssistChip(onClick = {}, label = { Text("${state.items.count { it.status != "CLOSED" }} open") }) + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat daftar regular sale...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada regular sale untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + RegularSaleListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun RegularSaleListCard( + item: RegularSaleListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("REGULAR SALE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.saleNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUYER", item.buyer.name) + LotMetaCell("TANGGAL", item.saleDate, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("ITEM", item.itemCount.toString(), emphasize = true) + LotMetaCell("TOTAL", formatCurrency(item.totalNominalBuyer), alignEnd = true, emphasize = true) + } + if (item.totalAgentCommission > 0) { + Text( + "Komisi agen ${formatCurrency(item.totalAgentCommission)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun RegularSaleDetailScreen( + role: String, + state: SalesRegularUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdateQtySold: (Int, String) -> Unit, + onUpdateQtyReturned: (Int, String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + val detail = state.selectedSale + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail regular sale...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail regular sale belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Sales", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("REGULAR SALE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.saleNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUYER", detail.buyer.name) + LotMetaCell("TANGGAL", detail.saleDate, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("COURIER", detail.courier?.name ?: "-") + LotMetaCell("CLOSE", detail.closeDate ?: "-", alignEnd = true) + } + if (!detail.notes.isNullOrBlank()) { + Text(detail.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + item { + RegularSaleSummarySection(detail = detail) + } + + item { + RegularSaleLinesSection(lines = detail.lines) + } + + if (role == "SALES" && detail.status != "CLOSED") { + item { + RegularSaleCloseSection( + state = state, + lines = detail.lines, + onUpdateCloseDate = onUpdateCloseDate, + onUpdateQtySold = onUpdateQtySold, + onUpdateQtyReturned = onUpdateQtyReturned, + onUpdatePriceActual = onUpdatePriceActual, + onCloseSale = onCloseSale, + ) + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun RegularSaleSummarySection(detail: RegularSaleDetail) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Ringkasan Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TOTAL BUYER", formatCurrency(detail.totalNominalBuyer), emphasize = true) + LotMetaCell("TOTAL COMPANY", formatCurrency(detail.totalNominalCompany), alignEnd = true, emphasize = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SHIPPING", formatCurrency(detail.shippingCostBuyer)) + LotMetaCell("KOMISI", formatCurrency(detail.totalAgentCommission), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("CURR", detail.buyerCurrencyCode) + LotMetaCell("RATE", detail.exchangeRate?.toString() ?: "-", alignEnd = true) + } + } + } +} + +@Composable +private fun RegularSaleLinesSection(lines: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Line Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lines.isEmpty()) { + EmptyStateCard("Belum ada line penjualan.") + } else { + lines.forEach { line -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.lotCode, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("GRADE", line.grade) + LotMetaCell("WAREHOUSE", line.warehouse, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("PLANNED", "${formatQuantity(line.qtyPlanned)} ${line.unitCode}", emphasize = true) + LotMetaCell("CURRENT", "${formatQuantity(line.currentAvailableQty)} ${line.unitCode}", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("PRICE PLAN", formatCurrency(line.sellingPricePlanned)) + LotMetaCell("UNIT COST", formatCurrency(line.unitCost), alignEnd = true) + } + Text( + listOfNotNull(line.location, line.agentName?.let { "Agen: $it" }).joinToString(" • ").ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun RegularSaleCloseSection( + state: SalesRegularUiState, + lines: List, + onUpdateCloseDate: (String) -> Unit, + onUpdateQtySold: (Int, String) -> Unit, + onUpdateQtyReturned: (Int, String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Tutup Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = state.closeDate, + onValueChange = onUpdateCloseDate, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Tanggal Close") }, + placeholder = { Text("YYYY-MM-DD") }, + shape = RoundedCornerShape(14.dp), + ) + state.closeLines.forEachIndexed { index, line -> + val sourceLine = lines.getOrNull(index) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.lotCode, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Text( + "Planned ${formatQuantity(line.qtyPlanned)} ${sourceLine?.unitCode ?: "kg"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = line.qtyActualSold, + onValueChange = { onUpdateQtySold(index, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Qty Terjual Aktual") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = line.qtyReturned, + onValueChange = { onUpdateQtyReturned(index, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Qty Retur") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = line.sellingPriceActual, + onValueChange = { onUpdatePriceActual(index, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Harga Jual Aktual") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + } + } + } + Button( + onClick = onCloseSale, + enabled = !state.isClosing, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isClosing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Tutup Penjualan") + } + } + } + } +} + +@Composable +private fun JitSalesModuleScreen( + role: String, + state: SalesJitUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + if (state.selectedSaleId != null || state.selectedSale != null) { + JitSaleDetailScreen( + role = role, + state = state, + modifier = modifier, + onBack = onBack, + onUpdateCloseDate = onUpdateCloseDate, + onUpdatePriceActual = onUpdatePriceActual, + onCloseSale = onCloseSale, + ) + } else { + JitSalesListScreen( + role = role, + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenDetail = onOpenDetail, + ) + } +} + +@Composable +private fun JitSalesListScreen( + role: String, + state: SalesJitUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Sales JIT", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + if (role == "SALES") "Pantau penjualan just in time dan tutup saat harga jual aktual sudah final." + else "Lihat transaksi just in time yang sedang berjalan beserta nilainya.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh jit sales") + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.items.size} jit sales") }) + AssistChip(onClick = {}, label = { Text("${state.items.count { it.status != "CLOSED" }} open") }) + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat daftar sales JIT...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada sales JIT untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + JitSaleListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun JitSaleListCard( + item: JitSaleListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("JIT SALE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.saleNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUYER", item.buyer.name) + LotMetaCell("TANGGAL", item.saleDate, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("ITEM", item.itemCount.toString(), emphasize = true) + LotMetaCell("TOTAL", formatCurrency(item.totalNominalBuyer), alignEnd = true, emphasize = true) + } + if (item.totalAgentCommission > 0) { + Text( + "Komisi agen ${formatCurrency(item.totalAgentCommission)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun JitSaleDetailScreen( + role: String, + state: SalesJitUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + val detail = state.selectedSale + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail sales JIT...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail sales JIT belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Sales JIT", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("JIT SALE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.saleNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUYER", detail.buyer.name) + LotMetaCell("TANGGAL", detail.saleDate, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("COURIER", detail.courier?.name ?: "-") + LotMetaCell("CLOSE", detail.closeDate ?: "-", alignEnd = true) + } + } + } + } + + item { JitSaleSummarySection(detail = detail) } + item { JitSaleLinesSection(lines = detail.lines) } + + if (role == "SALES" && detail.status != "CLOSED") { + item { + JitSaleCloseSection( + state = state, + onUpdateCloseDate = onUpdateCloseDate, + onUpdatePriceActual = onUpdatePriceActual, + onCloseSale = onCloseSale, + ) + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun JitSaleSummarySection(detail: JitSaleDetail) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Ringkasan Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TOTAL BUYER", formatCurrency(detail.totalNominalBuyer), emphasize = true) + LotMetaCell("TOTAL COMPANY", formatCurrency(detail.totalNominalCompany), alignEnd = true, emphasize = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SHIPPING", formatCurrency(detail.shippingCostBuyer)) + LotMetaCell("KOMISI", formatCurrency(detail.totalAgentCommission), alignEnd = true) + } + } + } +} + +@Composable +private fun JitSaleLinesSection(lines: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Line Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lines.isEmpty()) { + EmptyStateCard("Belum ada line penjualan.") + } else { + lines.forEach { line -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.grade.name, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("QTY", "${formatQuantity(line.qtyPlanned)} kg", emphasize = true) + LotMetaCell("MAL", formatCurrency(line.malUnitPrice), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("PLAN", formatCurrency(line.sellingPricePlanned)) + LotMetaCell("ACTUAL", line.sellingPriceActual?.let(::formatCurrency) ?: "-", alignEnd = true) + } + Text( + listOfNotNull(line.agentName, line.profitShareScheme?.name).joinToString(" • ").ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun JitSaleCloseSection( + state: SalesJitUiState, + onUpdateCloseDate: (String) -> Unit, + onUpdatePriceActual: (Int, String) -> Unit, + onCloseSale: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Tutup Penjualan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = state.closeDate, + onValueChange = onUpdateCloseDate, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Tanggal Close") }, + placeholder = { Text("YYYY-MM-DD") }, + shape = RoundedCornerShape(14.dp), + ) + state.closeLines.forEachIndexed { index, line -> + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.gradeName, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Text( + "Qty ${formatQuantity(line.qtyPlanned)} kg", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = line.sellingPriceActual, + onValueChange = { onUpdatePriceActual(index, it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Harga Jual Aktual") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + } + } + } + Button( + onClick = onCloseSale, + enabled = !state.isClosing, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isClosing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Tutup Penjualan") + } + } + } + } +} + +@Composable +private fun ConsignmentsModuleScreen( + role: String, + state: ConsignmentsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, + onSelectLine: (String?) -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdateSellingPrice: (String) -> Unit, + onUpdateQtySold: (String) -> Unit, + onUpdateQtyReturned: (String) -> Unit, + onUpdateSalesCommission: (String) -> Unit, + onCloseLine: () -> Unit, +) { + if (state.selectedConsignmentId != null || state.selectedConsignment != null) { + ConsignmentDetailScreen( + role = role, + state = state, + modifier = modifier, + onBack = onBack, + onSelectLine = onSelectLine, + onUpdateCloseDate = onUpdateCloseDate, + onUpdateSellingPrice = onUpdateSellingPrice, + onUpdateQtySold = onUpdateQtySold, + onUpdateQtyReturned = onUpdateQtyReturned, + onUpdateSalesCommission = onUpdateSalesCommission, + onCloseLine = onCloseLine, + ) + } else { + ConsignmentsListScreen( + role = role, + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenDetail = onOpenDetail, + ) + } +} + +@Composable +private fun ConsignmentsListScreen( + role: String, + state: ConsignmentsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Titip Jual", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + if (role == "SALES") "Pantau barang titip jual dan tutup tiap item saat hasil penjualan sudah final." + else "Lihat transaksi titip jual beserta jumlah item yang masih terbuka.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh consignments") + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.items.size} consignments") }) + AssistChip(onClick = {}, label = { Text("${state.items.sumOf { it.openItemCount }} open items") }) + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat daftar titip jual...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada transaksi titip jual untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + ConsignmentListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun ConsignmentListCard( + item: ConsignmentListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("TITIP JUAL", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.consignmentNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SALES", item.sales.name) + LotMetaCell("BUYER", item.buyer.name, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("ITEM", "${item.openItemCount}/${item.itemCount}", emphasize = true) + LotMetaCell("TITIP", "${formatQuantity(item.totalQtyConsigned)} kg", alignEnd = true, emphasize = true) + } + } + } +} + +@Composable +private fun ConsignmentDetailScreen( + role: String, + state: ConsignmentsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onSelectLine: (String?) -> Unit, + onUpdateCloseDate: (String) -> Unit, + onUpdateSellingPrice: (String) -> Unit, + onUpdateQtySold: (String) -> Unit, + onUpdateQtyReturned: (String) -> Unit, + onUpdateSalesCommission: (String) -> Unit, + onCloseLine: () -> Unit, +) { + val detail = state.selectedConsignment + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail titip jual...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail titip jual belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Titip Jual", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("TITIP JUAL", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.consignmentNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SALES", detail.sales.name) + LotMetaCell("BUYER", detail.buyer.name, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TANGGAL", detail.consignmentDate) + LotMetaCell("LINES", detail.lines.size.toString(), alignEnd = true) + } + } + } + } + + item { ConsignmentLinesSection(lines = detail.lines, onSelectLine = onSelectLine) } + + if (role == "SALES" && state.selectedLineId != null) { + item { + ConsignmentCloseSection( + state = state, + line = detail.lines.find { it.id == state.selectedLineId }, + onUpdateCloseDate = onUpdateCloseDate, + onUpdateSellingPrice = onUpdateSellingPrice, + onUpdateQtySold = onUpdateQtySold, + onUpdateQtyReturned = onUpdateQtyReturned, + onUpdateSalesCommission = onUpdateSalesCommission, + onCloseLine = onCloseLine, + ) + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun ConsignmentLinesSection( + lines: List, + onSelectLine: (String?) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Line Titip Jual", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lines.isEmpty()) { + EmptyStateCard("Belum ada line titip jual.") + } else { + lines.forEach { line -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .clickable { if (line.status != "CLOSED") onSelectLine(line.id) }, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text(line.lotCode, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) + StatusPill(status = line.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("GRADE", line.grade) + LotMetaCell("TITIP", "${formatQuantity(line.qtyConsigned)} ${line.unitCode}", alignEnd = true, emphasize = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SOLD", "${formatQuantity(line.qtySold)} ${line.unitCode}") + LotMetaCell("RETURN", "${formatQuantity(line.qtyReturned)} ${line.unitCode}", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SELL", line.sellingPrice?.let(::formatCurrency) ?: "-") + LotMetaCell("COMM", formatCurrency(line.salesCommission), alignEnd = true) + } + } + } + } + } + } +} + +@Composable +private fun ConsignmentCloseSection( + state: ConsignmentsUiState, + line: ConsignmentLineDetail?, + onUpdateCloseDate: (String) -> Unit, + onUpdateSellingPrice: (String) -> Unit, + onUpdateQtySold: (String) -> Unit, + onUpdateQtyReturned: (String) -> Unit, + onUpdateSalesCommission: (String) -> Unit, + onCloseLine: () -> Unit, +) { + if (line == null) return + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Tutup Item Titip Jual", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Text("${line.lotCode} • ${formatQuantity(line.qtyConsigned)} ${line.unitCode}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = state.closeDate, + onValueChange = onUpdateCloseDate, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Tanggal Close") }, + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = state.sellingPrice, + onValueChange = onUpdateSellingPrice, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Harga Jual") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.qtySold, + onValueChange = onUpdateQtySold, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Qty Sold") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + OutlinedTextField( + value = state.qtyReturned, + onValueChange = onUpdateQtyReturned, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("Qty Return") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + } + OutlinedTextField( + value = state.salesCommission, + onValueChange = onUpdateSalesCommission, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Komisi Sales") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + Button( + onClick = onCloseLine, + enabled = !state.isClosing, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isClosing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Tutup Item") + } + } + } + } +} + +@Composable +private fun FundRequestsModuleScreen( + state: FundRequestsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onUpdateTransferType: (String) -> Unit, + onUpdateReferenceNo: (String) -> Unit, + onSelectAgent: (String) -> Unit, + onSelectAgentBank: (String) -> Unit, + onSelectCompanyBank: (String) -> Unit, + onUpdateAmount: (String) -> Unit, + onUpdateTransferredAt: (String) -> Unit, + onSave: () -> Unit, +) { + val selectedAgent = remember(state.agents, state.selectedAgentId) { + state.agents.find { it.id == state.selectedAgentId } + } + val agentBanks = selectedAgent?.bankAccounts.orEmpty() + var transferTypeExpanded by remember { mutableStateOf(false) } + var agentExpanded by remember { mutableStateOf(false) } + var agentBankExpanded by remember { mutableStateOf(false) } + var companyBankExpanded by remember { mutableStateOf(false) } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Permintaan Dana", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Catat transfer modal atau bagi hasil ke agen, lalu pantau riwayat pengirimannya.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh fund requests") + } + } + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Buat Permintaan Dana", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + + Box(modifier = Modifier.fillMaxWidth()) { + PickerField( + label = "Tipe Transfer", + value = if (state.transferType == "PROFIT_SHARE") "Bagi Hasil" else "Modal", + onClick = { transferTypeExpanded = true }, + ) + DropdownMenu(expanded = transferTypeExpanded, onDismissRequest = { transferTypeExpanded = false }) { + listOf("CAPITAL" to "Modal", "PROFIT_SHARE" to "Bagi Hasil").forEach { (code, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + onUpdateTransferType(code) + transferTypeExpanded = false + }, + ) + } + } + } + + OutlinedTextField( + value = state.referenceNo, + onValueChange = onUpdateReferenceNo, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("No Referensi") }, + shape = RoundedCornerShape(14.dp), + ) + + Box(modifier = Modifier.fillMaxWidth()) { + PickerField( + label = "Agen", + value = selectedAgent?.let { "${it.code} · ${it.name}" } ?: "Pilih agen", + onClick = { agentExpanded = true }, + ) + DropdownMenu(expanded = agentExpanded, onDismissRequest = { agentExpanded = false }) { + state.agents.forEach { agent -> + DropdownMenuItem( + text = { + Column { + Text("${agent.code} · ${agent.name}", fontWeight = FontWeight.SemiBold) + Text( + "Profit ${formatCurrency(agent.profitShareBalance)} • Modal ${formatCurrency(agent.capitalBalance)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + onSelectAgent(agent.id) + agentExpanded = false + }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + PickerField( + label = "Rekening Agen", + value = agentBanks.find { it.id == state.selectedAgentBankAccountId }?.let { "${it.bankName} · ${it.accountNumber}" } ?: "Pilih rekening agen", + onClick = { agentBankExpanded = true }, + ) + DropdownMenu(expanded = agentBankExpanded, onDismissRequest = { agentBankExpanded = false }) { + agentBanks.forEach { bank -> + DropdownMenuItem( + text = { Text("${bank.bankName} · ${bank.accountNumber}") }, + onClick = { + onSelectAgentBank(bank.id) + agentBankExpanded = false + }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + PickerField( + label = "Rekening Kantor", + value = state.companyBankAccounts.find { it.id == state.selectedCompanyBankAccountId }?.let { "${it.bankName} · ${it.accountNumber}" } ?: "Pilih rekening kantor", + onClick = { companyBankExpanded = true }, + ) + DropdownMenu(expanded = companyBankExpanded, onDismissRequest = { companyBankExpanded = false }) { + state.companyBankAccounts.forEach { bank -> + DropdownMenuItem( + text = { Text("${bank.bankName} · ${bank.accountNumber}") }, + onClick = { + onSelectCompanyBank(bank.id) + companyBankExpanded = false + }, + ) + } + } + } + + OutlinedTextField( + value = state.amount, + onValueChange = onUpdateAmount, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Nominal ${state.currencyCode}") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + shape = RoundedCornerShape(14.dp), + ) + + OutlinedTextField( + value = state.transferredAt, + onValueChange = onUpdateTransferredAt, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Waktu Transfer") }, + placeholder = { Text("2026-05-21T09:30") }, + shape = RoundedCornerShape(14.dp), + ) + + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Simpan Permintaan Dana") + } + } + } + } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.items.size} request") }) + AssistChip(onClick = {}, label = { Text(state.currencyCode) }) + } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat riwayat fund request...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada fund request untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + FundRequestCard(item = item) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun PickerField( + label: String, + value: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium) + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun FundRequestCard(item: FundRequestListItem) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("FUND REQUEST", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.requestNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TIPE", prettifyStatus(item.transferType)) + LotMetaCell("REFERENSI", item.referenceNo, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("AGEN", item.agent.name) + LotMetaCell("NOMINAL", formatCurrency(item.amount), alignEnd = true, emphasize = true) + } + Text( + "${item.agentBankAccount.bankName} ${item.agentBankAccount.accountNumber} • ${item.companyBankName} ${item.companyBankAccountNumber}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PurchasesModuleScreen( + role: String, + state: PurchasesUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, + onOpenEdit: () -> Unit, + onCloseEditor: () -> Unit, + onUpdatePurchaseDate: (String) -> Unit, + onUpdateReceivedAt: (String) -> Unit, + onSelectEmployee: (String) -> Unit, + onSelectWarehouse: (String) -> Unit, + onSelectWarehouseLocation: (String?) -> Unit, + onUpdateNotes: (String) -> Unit, + onAddLine: () -> Unit, + onRemoveLine: (Int) -> Unit, + onSelectLineGrade: (Int, String?) -> Unit, + onUpdateLineQty: (Int, String) -> Unit, + onSelectLineUnit: (Int, String) -> Unit, + onUpdateLineUnitPrice: (Int, String) -> Unit, + onUpdateLineUnitCost: (Int, String) -> Unit, + onUpdateLineNotes: (Int, String) -> Unit, + onSave: () -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, +) { + when (state.screen) { + PurchasesScreen.Create, PurchasesScreen.Edit -> PurchaseEditorScreen( + state = state, + modifier = modifier, + onBack = onCloseEditor, + onUpdatePurchaseDate = onUpdatePurchaseDate, + onUpdateReceivedAt = onUpdateReceivedAt, + onSelectEmployee = onSelectEmployee, + onSelectWarehouse = onSelectWarehouse, + onSelectWarehouseLocation = onSelectWarehouseLocation, + onUpdateNotes = onUpdateNotes, + onAddLine = onAddLine, + onRemoveLine = onRemoveLine, + onSelectLineGrade = onSelectLineGrade, + onUpdateLineQty = onUpdateLineQty, + onSelectLineUnit = onSelectLineUnit, + onUpdateLineUnitPrice = onUpdateLineUnitPrice, + onUpdateLineUnitCost = onUpdateLineUnitCost, + onUpdateLineNotes = onUpdateLineNotes, + onSave = onSave, + ) + + PurchasesScreen.Detail -> { + PurchaseDetailScreen( + role = role, + state = state, + modifier = modifier, + onBack = onBack, + onEdit = onOpenEdit, + onSubmit = onSubmit, + onCancel = onCancel, + ) + } + + else -> { + PurchasesListScreen( + role = role, + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenCreate = onOpenCreate, + onOpenDetail = onOpenDetail, + ) + } + } +} + +@Composable +private fun PurchasesListScreen( + role: String, + state: PurchasesUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Purchases", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + if (role == "PURCHASING") "Pantau draft pembelian, lalu submit saat data sudah lengkap." + else "Lihat status pembelian dan grand total transaksi yang sedang berjalan.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (role == "PURCHASING") { + Button( + onClick = onOpenCreate, + shape = RoundedCornerShape(16.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Baru") + } + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh purchases") + } + } + } + } + } + + item { + AssistChip(onClick = {}, label = { Text("${state.items.size} purchase") }) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat daftar purchase...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada purchase untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + PurchaseListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun PurchaseListCard( + item: PurchaseListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("PURCHASE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.purchaseNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("AGEN", item.agent?.name ?: "-") + LotMetaCell("TANGGAL", item.purchaseDate, alignEnd = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("LINES", item.lineCount.toString(), emphasize = true) + LotMetaCell("TOTAL", formatCurrency(item.grandTotal), alignEnd = true, emphasize = true) + } + } + } +} + +@Composable +private fun PurchaseDetailScreen( + role: String, + state: PurchasesUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onEdit: () -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, +) { + val detail = state.selectedPurchase + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail purchase...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail purchase belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Purchase", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("PURCHASE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.purchaseNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("AGEN", detail.agent?.name ?: "-") + LotMetaCell("TANGGAL", detail.purchaseDate, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("RECEIVED", detail.receivedAt?.let(::formatApiDateTime) ?: "-") + LotMetaCell("LINES", detail.lines.size.toString(), alignEnd = true, emphasize = true) + } + if (!detail.notes.isNullOrBlank()) { + Text(detail.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + item { + PurchaseSummarySection(detail = detail) + } + + item { + PurchaseLinesSection(lines = detail.lines) + } + + if (role == "PURCHASING" && detail.status == "DRAFT") { + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = onEdit, + enabled = !state.isActing, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Text("Edit Draft") + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = onCancel, + enabled = !state.isActing, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Batalkan") + } + Button( + onClick = onSubmit, + enabled = !state.isActing, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isActing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Submit") + } + } + } + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun PurchaseSummarySection(detail: PurchaseDetail) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Ringkasan Biaya", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("SHIPPING", detail.shippingCost?.let(::formatCurrency) ?: "-") + LotMetaCell("OPERASIONAL", detail.incomingOperationalCost?.let(::formatCurrency) ?: "-", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("AFTER ARRIVAL", detail.afterArrivalOperationalCost?.let(::formatCurrency) ?: "-") + LotMetaCell( + "AVG PRICE", + detail.analysis?.averagePrice?.let(::formatCurrency) ?: "-", + alignEnd = true, + ) + } + detail.analysis?.let { analysis -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("WEIGHT BUY", analysis.weightBuy?.let(::formatQuantity) ?: "-") + LotMetaCell("WEIGHT FINAL", analysis.weightFinal?.let(::formatQuantity) ?: "-", alignEnd = true) + } + } + } + } +} + +@Composable +private fun PurchaseEditorScreen( + state: PurchasesUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onUpdatePurchaseDate: (String) -> Unit, + onUpdateReceivedAt: (String) -> Unit, + onSelectEmployee: (String) -> Unit, + onSelectWarehouse: (String) -> Unit, + onSelectWarehouseLocation: (String?) -> Unit, + onUpdateNotes: (String) -> Unit, + onAddLine: () -> Unit, + onRemoveLine: (Int) -> Unit, + onSelectLineGrade: (Int, String?) -> Unit, + onUpdateLineQty: (Int, String) -> Unit, + onSelectLineUnit: (Int, String) -> Unit, + onUpdateLineUnitPrice: (Int, String) -> Unit, + onUpdateLineUnitCost: (Int, String) -> Unit, + onUpdateLineNotes: (Int, String) -> Unit, + onSave: () -> Unit, +) { + var employeeExpanded by remember { mutableStateOf(false) } + var warehouseExpanded by remember { mutableStateOf(false) } + var locationExpanded by remember { mutableStateOf(false) } + val selectedWarehouse = state.warehouses.find { it.id == state.selectedWarehouseId } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = if (state.screen == PurchasesScreen.Edit) "Edit Purchase" else "Purchase Baru", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Header Purchase", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = state.purchaseDate, + onValueChange = onUpdatePurchaseDate, + label = { Text("Tanggal Pembelian") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = state.receivedAt, + onValueChange = onUpdateReceivedAt, + label = { Text("Waktu Diterima") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Box { + PickerField( + label = "Diterima Oleh", + value = state.employees.find { it.id == state.selectedEmployeeId }?.name ?: "Pilih karyawan", + onClick = { employeeExpanded = true }, + ) + DropdownMenu(expanded = employeeExpanded, onDismissRequest = { employeeExpanded = false }) { + state.employees.forEach { employee -> + DropdownMenuItem( + text = { Text(employee.name) }, + onClick = { + employeeExpanded = false + onSelectEmployee(employee.id) + }, + ) + } + } + } + Box { + PickerField( + label = "Gudang Default", + value = selectedWarehouse?.name ?: "Pilih gudang", + onClick = { warehouseExpanded = true }, + ) + DropdownMenu(expanded = warehouseExpanded, onDismissRequest = { warehouseExpanded = false }) { + state.warehouses.forEach { warehouse -> + DropdownMenuItem( + text = { Text(warehouse.name) }, + onClick = { + warehouseExpanded = false + onSelectWarehouse(warehouse.id) + }, + ) + } + } + } + if (selectedWarehouse?.locations?.isNotEmpty() == true) { + Box { + PickerField( + label = "Lokasi Default", + value = selectedWarehouse.locations.find { it.id == state.selectedWarehouseLocationId }?.name ?: "Pilih lokasi", + onClick = { locationExpanded = true }, + ) + DropdownMenu(expanded = locationExpanded, onDismissRequest = { locationExpanded = false }) { + selectedWarehouse.locations.forEach { location -> + DropdownMenuItem( + text = { Text(location.name) }, + onClick = { + locationExpanded = false + onSelectWarehouseLocation(location.id) + }, + ) + } + } + } + } + OutlinedTextField( + value = state.notes, + onValueChange = onUpdateNotes, + label = { Text("Catatan Purchase") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + } + } + } + + item { + SectionActionHeader( + title = "Line Purchase", + buttonLabel = "Tambah Line", + onClick = onAddLine, + ) + } + + items(state.lines.size, key = { "purchase-line-$it" }) { index -> + PurchaseLineEditor( + index = index, + line = state.lines[index], + grades = state.grades, + units = state.units, + warehouses = state.warehouses, + canRemove = state.lines.size > 1, + onRemove = { onRemoveLine(index) }, + onSelectGrade = { onSelectLineGrade(index, it) }, + onQtyChange = { onUpdateLineQty(index, it) }, + onSelectUnit = { onSelectLineUnit(index, it) }, + onUnitPriceChange = { onUpdateLineUnitPrice(index, it) }, + onUnitCostChange = { onUpdateLineUnitCost(index, it) }, + onNotesChange = { onUpdateLineNotes(index, it) }, + ) + } + + item { + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 14.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text(if (state.screen == PurchasesScreen.Edit) "Simpan Perubahan" else "Buat Draft Purchase") + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } +} + +@Composable +private fun PurchaseLineEditor( + index: Int, + line: PurchaseLineFormState, + grades: List, + units: List, + warehouses: List, + canRemove: Boolean, + onRemove: () -> Unit, + onSelectGrade: (String?) -> Unit, + onQtyChange: (String) -> Unit, + onSelectUnit: (String) -> Unit, + onUnitPriceChange: (String) -> Unit, + onUnitCostChange: (String) -> Unit, + onNotesChange: (String) -> Unit, +) { + var gradeExpanded by remember { mutableStateOf(false) } + var unitExpanded by remember { mutableStateOf(false) } + val warehouse = warehouses.find { it.id == line.selectedWarehouseId } + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Line ${index + 1}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + if (canRemove) { + Text("Hapus", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge, modifier = Modifier.clickable(onClick = onRemove)) + } + } + + Box { + PickerField( + label = "Grade", + value = grades.find { it.id == line.selectedGradeId }?.name ?: "Pilih grade", + onClick = { gradeExpanded = true }, + ) + DropdownMenu(expanded = gradeExpanded, onDismissRequest = { gradeExpanded = false }) { + DropdownMenuItem( + text = { Text("Tanpa grade") }, + onClick = { + gradeExpanded = false + onSelectGrade(null) + }, + ) + grades.forEach { grade -> + DropdownMenuItem( + text = { Text(grade.name) }, + onClick = { + gradeExpanded = false + onSelectGrade(grade.id) + }, + ) + } + } + } + + OutlinedTextField( + value = line.qtyOrdered, + onValueChange = onQtyChange, + label = { Text("Qty") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + + Box { + PickerField( + label = "Unit", + value = units.find { it.id == line.selectedUnitId }?.let { item -> listOfNotNull(item.code, item.name).joinToString(" • ") } ?: "Pilih unit", + onClick = { unitExpanded = true }, + ) + DropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) { + units.forEach { unit -> + DropdownMenuItem( + text = { Text(listOfNotNull(unit.code, unit.name).joinToString(" • ")) }, + onClick = { + unitExpanded = false + onSelectUnit(unit.id) + }, + ) + } + } + } + + OutlinedTextField( + value = line.unitPrice, + onValueChange = onUnitPriceChange, + label = { Text("Harga Beli") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + OutlinedTextField( + value = line.unitCost, + onValueChange = onUnitCostChange, + label = { Text("Unit Cost") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + if (warehouse != null) { + Text( + "Gudang line mengikuti default: ${listOfNotNull(warehouse.name, warehouse.locations.find { it.id == line.selectedWarehouseLocationId }?.name).joinToString(" • ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedTextField( + value = line.notes, + onValueChange = onNotesChange, + label = { Text("Catatan Line") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + } + } +} + +@Composable +private fun PurchaseAnalysesModuleScreen( + state: PurchaseAnalysesUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, +) { + if (state.selectedPurchaseId != null || state.selectedDetail != null) { + PurchaseAnalysisDetailScreen(state = state, modifier = modifier, onBack = onBack) + } else { + PurchaseAnalysesListScreen(state = state, modifier = modifier, onRefresh = onRefresh, onOpenDetail = onOpenDetail) + } +} + +@Composable +private fun PurchaseAnalysesListScreen( + state: PurchaseAnalysesUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn(modifier = modifier.padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text("Purchase Analyses", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text("Pantau analisis berat, modal, dan laba rugi setiap purchase.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + else Icon(Icons.Outlined.Refresh, contentDescription = "Refresh analyses") + } + } + } + if (!state.inlineError.isNullOrBlank()) item { InlineErrorCard(message = state.inlineError) } + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat analisis purchase...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada analisis purchase.") } + } else { + items(state.items, key = { it.purchaseId }) { item -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().clickable { onOpenDetail(item.purchaseId) }, + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column { + Text("PURCHASE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.purchaseNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.analysisStatus) + } + Text(item.supplierName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUY", item.weightBuy?.let(::formatQuantity) ?: "-") + LotMetaCell("FINAL", item.weightFinal?.let(::formatQuantity) ?: "-", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("MODAL", formatCurrency(item.totalModalBeli), emphasize = true) + LotMetaCell("L/R", item.totalLabaRugi?.let(::formatCurrency) ?: "-", alignEnd = true, emphasize = true) + } + } + } + } + } + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun PurchaseAnalysisDetailScreen( + state: PurchaseAnalysesUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, +) { + val detail = state.selectedDetail + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { LoadingCard("Memuat detail analisis...") } + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { EmptyStateCard("Detail analisis belum tersedia.") } + else -> LazyColumn(modifier = modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Analisis", onBack = onBack) + } + if (!state.inlineError.isNullOrBlank()) item { InlineErrorCard(message = state.inlineError) } + item { + Surface(shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest, shadowElevation = 3.dp) { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(detail.purchase.purchaseNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text(detail.purchase.supplierName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("BUY", formatQuantity(detail.actuals.weightBuy)) + LotMetaCell("FINAL", formatQuantity(detail.actuals.weightFinal), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TOTAL MODAL", formatCurrency(detail.summary.totalModalBeli), emphasize = true) + LotMetaCell("L/R", detail.summary.totalLabaRugi?.let(::formatCurrency) ?: "-", alignEnd = true, emphasize = true) + } + } + } + } + item { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest, shadowElevation = 2.dp) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Ringkasan Analisis", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("AVG PRICE", detail.inputs.averagePrice?.let(::formatCurrency) ?: "-") + LotMetaCell("OPERASIONAL", formatCurrency(detail.summary.operasional), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("MODAL BARANG", formatCurrency(detail.summary.modalBarang)) + LotMetaCell("MODAL MAL", detail.summary.totalModalMal?.let(::formatCurrency) ?: "-", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("LABA/KG", detail.summary.labaTotalPerKg?.let(::formatCurrency) ?: "-") + LotMetaCell("AGEN/KG", detail.summary.labaAgenPerKg?.let(::formatCurrency) ?: "-", alignEnd = true) + } + } + } + } + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } +} + +@Composable +private fun PurchaseRealizationsModuleScreen( + state: PurchaseRealizationsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, +) { + if (state.selectedPurchaseId != null || state.selectedDetail != null) { + PurchaseRealizationDetailScreen(state = state, modifier = modifier, onBack = onBack) + } else { + PurchaseRealizationsListScreen(state = state, modifier = modifier, onRefresh = onRefresh, onOpenDetail = onOpenDetail) + } +} + +@Composable +private fun PurchaseRealizationsListScreen( + state: PurchaseRealizationsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn(modifier = modifier.padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text("Purchase Realizations", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text("Lihat sisa qty, revenue, dan profit dari purchase yang sudah berjalan.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + else Icon(Icons.Outlined.Refresh, contentDescription = "Refresh realizations") + } + } + } + if (!state.inlineError.isNullOrBlank()) item { InlineErrorCard(message = state.inlineError) } + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat realisasi purchase...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada realisasi purchase.") } + } else { + items(state.items, key = { it.purchaseId }) { item -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().clickable { onOpenDetail(item.purchaseId) }, + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column { + Text("PURCHASE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.purchaseNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Text(item.supplierName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("OPENING", formatQuantity(item.qtyOpening)) + LotMetaCell("SISA", formatQuantity(item.qtyRemaining), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("REVENUE", formatCurrency(item.revenueTotal), emphasize = true) + LotMetaCell("PROFIT", formatCurrency(item.profitLossTotal), alignEnd = true, emphasize = true) + } + } + } + } + } + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun PurchaseRealizationDetailScreen( + state: PurchaseRealizationsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, +) { + val detail = state.selectedDetail + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { LoadingCard("Memuat detail realisasi...") } + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { EmptyStateCard("Detail realisasi belum tersedia.") } + else -> LazyColumn(modifier = modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Realisasi", onBack = onBack) + } + if (!state.inlineError.isNullOrBlank()) item { InlineErrorCard(message = state.inlineError) } + item { + Surface(shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest, shadowElevation = 3.dp) { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(detail.purchase.purchaseNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text(detail.purchase.supplierName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("OPENING", formatQuantity(detail.summary.qtyOpening)) + LotMetaCell("SISA", formatQuantity(detail.summary.qtyRemaining), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("REVENUE", formatCurrency(detail.summary.revenueTotal), emphasize = true) + LotMetaCell("PROFIT", formatCurrency(detail.summary.profitLossTotal), alignEnd = true, emphasize = true) + } + } + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Riwayat Entry", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (detail.entries.isEmpty()) { + EmptyStateCard("Belum ada entry realisasi.") + } else { + detail.entries.forEach { entry -> + PurchaseRealizationEntryCard(entry = entry) + } + } + } + } + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } +} + +@Composable +private fun PurchaseRealizationEntryCard(entry: PurchaseRealizationEntryItem) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest, shadowElevation = 2.dp) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("EVENT", prettifyStatus(entry.eventType), emphasize = true) + LotMetaCell("WAKTU", formatApiDateTime(entry.occurredAt), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("LOT", entry.lotCode ?: "-") + LotMetaCell("REF", entry.referenceType, alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("QTY IN", formatQuantity(entry.qtyIn)) + LotMetaCell("QTY OUT", formatQuantity(entry.qtyOut), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("COST", formatCurrency(entry.amountCost)) + LotMetaCell("PROFIT", formatCurrency(entry.amountProfit), alignEnd = true) + } + if (!entry.notes.isNullOrBlank()) { + Text(entry.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun PurchaseLinesSection(lines: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Purchase Lines", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (lines.isEmpty()) { + EmptyStateCard("Belum ada line purchase.") + } else { + lines.forEach { line -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(line.grade?.name ?: "-", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("ORDERED", "${formatQuantity(line.qtyOrdered)} ${line.unit.code}", emphasize = true) + LotMetaCell("RECEIVED", "${formatQuantity(line.qtyReceived)} ${line.unit.code}", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("ACCEPTED", "${formatQuantity(line.qtyAccepted)} ${line.unit.code}") + LotMetaCell("REJECTED", "${formatQuantity(line.qtyRejected)} ${line.unit.code}", alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("UNIT PRICE", formatCurrency(line.unitPrice)) + LotMetaCell("SUBTOTAL", formatCurrency(line.subtotal), alignEnd = true, emphasize = true) + } + Text( + listOfNotNull(line.warehouse?.name, line.warehouseLocation?.name).joinToString(" • ").ifBlank { "-" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun LotTransformationsModuleScreen( + state: LotTransformationsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenDetail: (String) -> Unit, + onBack: () -> Unit, + onUpdateType: (String) -> Unit, + onUpdateDate: (String) -> Unit, + onUpdateRemainderMode: (String?) -> Unit, + onUpdateProcessingLossMode: (String?) -> Unit, + onUpdateNotes: (String) -> Unit, + onAddInput: () -> Unit, + onRemoveInput: (Int) -> Unit, + onUpdateInputQuery: (Int, String) -> Unit, + onSelectInputLot: (Int, String) -> Unit, + onClearInputLot: (Int) -> Unit, + onUpdateInputQty: (Int, String) -> Unit, + onUpdateInputNotes: (Int, String) -> Unit, + onAddOutput: () -> Unit, + onRemoveOutput: (Int) -> Unit, + onSelectOutputGrade: (Int, String) -> Unit, + onSelectOutputWarehouse: (Int, String) -> Unit, + onSelectOutputLocation: (Int, String?) -> Unit, + onUpdateOutputQty: (Int, String) -> Unit, + onUpdateOutputNotes: (Int, String) -> Unit, + onSave: () -> Unit, +) { + when { + state.screen == LotTransformationsScreen.Create -> LotTransformationCreateScreen( + state = state, + modifier = modifier, + onBack = onBack, + onUpdateType = onUpdateType, + onUpdateDate = onUpdateDate, + onUpdateRemainderMode = onUpdateRemainderMode, + onUpdateProcessingLossMode = onUpdateProcessingLossMode, + onUpdateNotes = onUpdateNotes, + onAddInput = onAddInput, + onRemoveInput = onRemoveInput, + onUpdateInputQuery = onUpdateInputQuery, + onSelectInputLot = onSelectInputLot, + onClearInputLot = onClearInputLot, + onUpdateInputQty = onUpdateInputQty, + onUpdateInputNotes = onUpdateInputNotes, + onAddOutput = onAddOutput, + onRemoveOutput = onRemoveOutput, + onSelectOutputGrade = onSelectOutputGrade, + onSelectOutputWarehouse = onSelectOutputWarehouse, + onSelectOutputLocation = onSelectOutputLocation, + onUpdateOutputQty = onUpdateOutputQty, + onUpdateOutputNotes = onUpdateOutputNotes, + onSave = onSave, + ) + + state.selectedTransformationId != null || state.selectedTransformation != null || state.screen == LotTransformationsScreen.Detail -> { + LotTransformationDetailScreen( + state = state, + modifier = modifier, + onBack = onBack, + ) + } + + else -> { + LotTransformationsListScreen( + state = state, + modifier = modifier, + onRefresh = onRefresh, + onOpenCreate = onOpenCreate, + onOpenDetail = onOpenDetail, + ) + } + } +} + +@Composable +private fun LotTransformationsListScreen( + state: LotTransformationsUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenDetail: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Lot Transformations", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Lihat riwayat mixing dan regrade, lalu buat transformasi baru dari lot aktif.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = onOpenCreate, + shape = RoundedCornerShape(16.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Buat") + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh transformations") + } + } + } + } + } + + item { + AssistChip(onClick = {}, label = { Text("${state.items.size} transformasi") }) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + if (state.isLoading && state.items.isEmpty()) { + item { LoadingCard("Memuat daftar transformasi lot...") } + } else if (state.items.isEmpty()) { + item { EmptyStateCard("Belum ada lot transformation untuk role ini.") } + } else { + items(state.items, key = { it.id }) { item -> + LotTransformationListCard(item = item, onClick = { onOpenDetail(item.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun LotTransformationCreateScreen( + state: LotTransformationsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onUpdateType: (String) -> Unit, + onUpdateDate: (String) -> Unit, + onUpdateRemainderMode: (String?) -> Unit, + onUpdateProcessingLossMode: (String?) -> Unit, + onUpdateNotes: (String) -> Unit, + onAddInput: () -> Unit, + onRemoveInput: (Int) -> Unit, + onUpdateInputQuery: (Int, String) -> Unit, + onSelectInputLot: (Int, String) -> Unit, + onClearInputLot: (Int) -> Unit, + onUpdateInputQty: (Int, String) -> Unit, + onUpdateInputNotes: (Int, String) -> Unit, + onAddOutput: () -> Unit, + onRemoveOutput: (Int) -> Unit, + onSelectOutputGrade: (Int, String) -> Unit, + onSelectOutputWarehouse: (Int, String) -> Unit, + onSelectOutputLocation: (Int, String?) -> Unit, + onUpdateOutputQty: (Int, String) -> Unit, + onUpdateOutputNotes: (Int, String) -> Unit, + onSave: () -> Unit, +) { + var typeExpanded by remember { mutableStateOf(false) } + var remainderExpanded by remember { mutableStateOf(false) } + var lossExpanded by remember { mutableStateOf(false) } + val totalInput = state.inputs.sumOf { it.qtyUsed.toDoubleOrNull() ?: 0.0 } + val totalOutput = state.outputs.sumOf { it.qtyProduced.toDoubleOrNull() ?: 0.0 } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Buat Transformasi", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text("Header Transformasi", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + + Box { + PickerField( + label = "Tipe Transformasi", + value = state.transformationTypes.find { it.code == state.transformationType }?.label ?: state.transformationType, + onClick = { typeExpanded = true }, + ) + DropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) { + state.transformationTypes.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + typeExpanded = false + onUpdateType(option.code) + }, + ) + } + } + } + + OutlinedTextField( + value = state.transformationDate, + onValueChange = onUpdateDate, + label = { Text("Tanggal Transformasi") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (state.transformationType == "REGRADE") { + Box { + PickerField( + label = "Mode Sisa Lot Sumber", + value = state.remainderModes.find { it.code == state.remainderMode }?.label ?: "Pilih mode sisa", + onClick = { remainderExpanded = true }, + ) + DropdownMenu(expanded = remainderExpanded, onDismissRequest = { remainderExpanded = false }) { + state.remainderModes.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + remainderExpanded = false + onUpdateRemainderMode(option.code) + }, + ) + } + } + } + + Box { + PickerField( + label = "Mode Selisih / Loss", + value = state.processingLossModes.find { it.code == state.processingLossMode }?.label ?: "Pilih mode loss", + onClick = { lossExpanded = true }, + ) + DropdownMenu(expanded = lossExpanded, onDismissRequest = { lossExpanded = false }) { + state.processingLossModes.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + lossExpanded = false + onUpdateProcessingLossMode(option.code) + }, + ) + } + } + } + } + + OutlinedTextField( + value = state.notes, + onValueChange = onUpdateNotes, + label = { Text("Catatan") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + } + } + } + + item { + SectionActionHeader( + title = "Lot Sumber", + buttonLabel = if (state.transformationType == "REGRADE") null else "Tambah Lot", + onClick = if (state.transformationType == "REGRADE") null else onAddInput, + ) + } + + items(state.inputs.size, key = { "input-$it" }) { index -> + val input = state.inputs[index] + LotTransformationInputEditor( + index = index, + input = input, + allLots = state.selectableLots, + canRemove = state.inputs.size > if (state.transformationType == "REGRADE") 1 else 2, + onRemove = { onRemoveInput(index) }, + onQueryChange = { onUpdateInputQuery(index, it) }, + onSelectLot = { onSelectInputLot(index, it) }, + onClearLot = { onClearInputLot(index) }, + onQtyChange = { onUpdateInputQty(index, it) }, + onNotesChange = { onUpdateInputNotes(index, it) }, + ) + } + + item { + SectionActionHeader( + title = "Lot Hasil", + buttonLabel = "Tambah Output", + onClick = onAddOutput, + ) + } + + items(state.outputs.size, key = { "output-$it" }) { index -> + LotTransformationOutputEditor( + index = index, + output = state.outputs[index], + grades = state.grades, + warehouses = state.warehouses, + canRemove = state.outputs.size > 1, + onRemove = { onRemoveOutput(index) }, + onSelectGrade = { onSelectOutputGrade(index, it) }, + onSelectWarehouse = { onSelectOutputWarehouse(index, it) }, + onSelectLocation = { onSelectOutputLocation(index, it) }, + onQtyChange = { onUpdateOutputQty(index, it) }, + onNotesChange = { onUpdateOutputNotes(index, it) }, + ) + } + + item { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Ringkasan Qty", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text("TOTAL INPUT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)) + Text( + "${formatQuantity(totalInput)} kg", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text("TOTAL OUTPUT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)) + Text( + "${formatQuantity(totalOutput)} kg", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + ) + } + } + if (state.transformationType == "REGRADE" && totalInput > totalOutput) { + Text( + "Selisih hasil regrade akan mengikuti mode loss yang dipilih.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } + + item { + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 14.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Text("Simpan Transformasi") + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } +} + +@Composable +private fun SectionActionHeader( + title: String, + buttonLabel: String?, + onClick: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (buttonLabel != null && onClick != null) { + Button(onClick = onClick, shape = RoundedCornerShape(14.dp)) { + Text(buttonLabel) + } + } + } +} + +@Composable +private fun LotTransformationInputEditor( + index: Int, + input: LotTransformationInputFormState, + allLots: List, + canRemove: Boolean, + onRemove: () -> Unit, + onQueryChange: (String) -> Unit, + onSelectLot: (String) -> Unit, + onClearLot: () -> Unit, + onQtyChange: (String) -> Unit, + onNotesChange: (String) -> Unit, +) { + val selectedLot = allLots.find { it.id == input.selectedLotId } + val filteredLots = remember(input.lotQuery, allLots) { + val query = input.lotQuery.trim() + if (query.isBlank() || selectedLot != null) { + emptyList() + } else { + allLots + .filter { it.lotCode.contains(query, ignoreCase = true) } + .take(6) + } + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Source ${index + 1}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + if (canRemove) { + Text( + "Hapus", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.clickable(onClick = onRemove), + ) + } + } + + if (selectedLot == null) { + OutlinedTextField( + value = input.lotQuery, + onValueChange = onQueryChange, + label = { Text("Cari Kode Lot") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (filteredLots.isEmpty() && input.lotQuery.isNotBlank()) { + Text("Tidak ada lot aktif yang cocok.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + filteredLots.forEach { lot -> + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { onSelectLot(lot.id) }, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(lot.lotCode, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Text( + "${lot.grade} • ${formatQuantity(lot.availableQty)} ${lot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + listOfNotNull(lot.warehouse, lot.location).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } else { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(selectedLot.lotCode, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text( + "Ganti", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.clickable(onClick = onClearLot), + ) + } + Text( + "${selectedLot.grade} • ${formatQuantity(selectedLot.availableQty)} ${selectedLot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + listOfNotNull(selectedLot.warehouse, selectedLot.location).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + + OutlinedTextField( + value = input.qtyUsed, + onValueChange = onQtyChange, + label = { Text("Qty Digunakan") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + OutlinedTextField( + value = input.notes, + onValueChange = onNotesChange, + label = { Text("Catatan Input") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + } + } +} + +@Composable +private fun LotTransformationOutputEditor( + index: Int, + output: LotTransformationOutputFormState, + grades: List, + warehouses: List, + canRemove: Boolean, + onRemove: () -> Unit, + onSelectGrade: (String) -> Unit, + onSelectWarehouse: (String) -> Unit, + onSelectLocation: (String?) -> Unit, + onQtyChange: (String) -> Unit, + onNotesChange: (String) -> Unit, +) { + var gradeExpanded by remember { mutableStateOf(false) } + var warehouseExpanded by remember { mutableStateOf(false) } + var locationExpanded by remember { mutableStateOf(false) } + val warehouse = warehouses.find { it.id == output.selectedWarehouseId } + val locations = warehouse?.locations.orEmpty() + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Output ${index + 1}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + if (canRemove) { + Text( + "Hapus", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.clickable(onClick = onRemove), + ) + } + } + + Box { + PickerField( + label = "Grade Hasil", + value = grades.find { it.id == output.selectedGradeId }?.name ?: "Pilih grade", + onClick = { gradeExpanded = true }, + ) + DropdownMenu(expanded = gradeExpanded, onDismissRequest = { gradeExpanded = false }) { + grades.forEach { grade -> + DropdownMenuItem( + text = { Text(grade.name) }, + onClick = { + gradeExpanded = false + onSelectGrade(grade.id) + }, + ) + } + } + } + + Box { + PickerField( + label = "Gudang Tujuan", + value = warehouse?.name ?: "Pilih gudang", + onClick = { warehouseExpanded = true }, + ) + DropdownMenu(expanded = warehouseExpanded, onDismissRequest = { warehouseExpanded = false }) { + warehouses.forEach { item -> + DropdownMenuItem( + text = { Text(item.name) }, + onClick = { + warehouseExpanded = false + onSelectWarehouse(item.id) + }, + ) + } + } + } + + if (locations.isNotEmpty()) { + Box { + PickerField( + label = "Lokasi Tujuan", + value = locations.find { it.id == output.selectedWarehouseLocationId }?.name ?: "Pilih lokasi", + onClick = { locationExpanded = true }, + ) + DropdownMenu(expanded = locationExpanded, onDismissRequest = { locationExpanded = false }) { + locations.forEach { item -> + DropdownMenuItem( + text = { Text(item.name) }, + onClick = { + locationExpanded = false + onSelectLocation(item.id) + }, + ) + } + } + } + } + + OutlinedTextField( + value = output.qtyProduced, + onValueChange = onQtyChange, + label = { Text("Qty Hasil") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + OutlinedTextField( + value = output.notes, + onValueChange = onNotesChange, + label = { Text("Catatan Output") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + } + } +} + +@Composable +private fun LotTransformationListCard( + item: LotTransformationListItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("TRANSFORMATION", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.transformationNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TIPE", prettifyStatus(item.transformationType)) + LotMetaCell("TANGGAL", formatApiDateTime(item.transformationDate), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("INPUT", "${item.inputCount} • ${formatQuantity(item.totalInputQty)} kg", emphasize = true) + LotMetaCell("OUTPUT", "${item.outputCount} • ${formatQuantity(item.totalOutputQty)} kg", alignEnd = true, emphasize = true) + } + } + } +} + +@Composable +private fun LotTransformationDetailScreen( + state: LotTransformationsUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, +) { + val detail = state.selectedTransformation + when { + state.isLoading && detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail transformasi...") + } + + detail == null -> Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail transformasi belum tersedia.") + } + + else -> { + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Detail Transformasi", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("TRANSFORMATION", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(detail.transformationNo, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = detail.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("TIPE", prettifyStatus(detail.transformationType)) + LotMetaCell("TANGGAL", formatApiDateTime(detail.transformationDate), alignEnd = true) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("REMAINDER", detail.remainderQty?.let { "${formatQuantity(it)} kg" } ?: "-") + LotMetaCell("LOSS", detail.processingLossQty?.let { "${formatQuantity(it)} kg" } ?: "-", alignEnd = true) + } + if (!detail.notes.isNullOrBlank()) { + Text(detail.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + item { TransformationInputsSection(inputs = detail.inputs) } + item { TransformationOutputsSection(outputs = detail.outputs) } + item { Spacer(modifier = Modifier.height(20.dp)) } + } + } + } +} + +@Composable +private fun TransformationInputsSection(inputs: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Input Lots", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (inputs.isEmpty()) { + EmptyStateCard("Belum ada input lot.") + } else { + inputs.forEach { input -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(input.sourceLot.lotCode, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("GRADE", input.sourceLot.grade) + LotMetaCell("USED", "${formatQuantity(input.qtyUsed)} ${input.sourceLot.unitCode}", alignEnd = true, emphasize = true) + } + Text( + listOfNotNull(input.sourceLot.warehouse, input.sourceLot.location).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun TransformationOutputsSection(outputs: List) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Output Lots", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (outputs.isEmpty()) { + EmptyStateCard("Belum ada output lot.") + } else { + outputs.forEach { output -> + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text(output.resultLot.lotCode, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary) + StatusPill(status = output.resultLot.status) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + LotMetaCell("GRADE", output.resultLot.grade) + LotMetaCell("PRODUCED", "${formatQuantity(output.qtyProduced)} ${output.resultLot.unitCode}", alignEnd = true, emphasize = true) + } + Text( + listOfNotNull(output.resultLot.warehouse, output.resultLot.location).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun StockAdjustmentCard(item: StockAdjustmentListItem) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("ADJUSTMENT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.adjustmentNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.reason.category) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("LOT", item.lot.lotCode) + LotMetaCell("TANGGAL", formatApiDateTime(item.adjustmentDate), alignEnd = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("SEBELUM", "${formatQuantity(item.availableQtyBefore)} ${item.lot.unitCode}") + LotMetaCell("SESUDAH", "${formatQuantity(item.availableQtyAfter)} ${item.lot.unitCode}", alignEnd = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell( + "QTY CHANGE", + "${if (item.qtyChange > 0) "+" else ""}${formatQuantity(item.qtyChange)} ${item.lot.unitCode}", + emphasize = true, + ) + LotMetaCell("ALASAN", item.reason.code, alignEnd = true) + } + Text( + "${item.reason.name} • ${item.createdBy.name}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!item.notes.isNullOrBlank()) { + Text(item.notes, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun WashingListScreen( + role: String, + state: WashingUiState, + modifier: Modifier = Modifier, + onRefresh: () -> Unit, + onOpenCreate: () -> Unit, + onOpenEdit: (String) -> Unit, + onOpenComplete: (String) -> Unit, +) { + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Washing", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Kelola proses pencucian lot aktif untuk role $role.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button( + onClick = onOpenCreate, + shape = RoundedCornerShape(16.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Baru") + } + } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Ringkasan Washing", style = MaterialTheme.typography.titleMedium) + Text( + "${state.washings.count { it.status == "IN_PROGRESS" }} proses berjalan • ${state.washings.count { it.status == "DONE" }} selesai", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh washing") + } + } + } + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${state.washings.size} washing") }) + AssistChip(onClick = {}, label = { Text("${state.selectableLots.size} lot siap") }) + } + } + + if (state.isLoading && state.washings.isEmpty()) { + item { LoadingCard("Memuat daftar washing...") } + } else if (state.washings.isEmpty()) { + item { EmptyStateCard("Belum ada data washing untuk role ini.") } + } else { + items(state.washings, key = { it.id }) { item -> + WashingCard( + item = item, + onEdit = { onOpenEdit(item.id) }, + onComplete = { onOpenComplete(item.id) }, + ) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun WashingCard( + item: WashingListItem, + onEdit: () -> Unit, + onComplete: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("NO WASHING", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.washingNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("LOT", item.lot.lotCode) + LotMetaCell("TEMPAT", item.washingPlace.name, alignEnd = true) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("SEBELUM", "${formatQuantity(item.beforeQty)} ${item.lot.unitCode}", emphasize = true) + LotMetaCell( + "SESUDAH", + item.afterQty?.let { "${formatQuantity(it)} ${item.lot.unitCode}" } ?: "-", + alignEnd = true, + emphasize = item.afterQty != null, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("GRADE", item.beforeGradeName ?: item.lot.gradeName ?: "-") + LotMetaCell("DURASI", "${item.durationHours} jam", alignEnd = true) + } + + Text( + "${item.beforeWarehouseName}${item.beforeLocationName?.let { " • $it" } ?: ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onEdit, + enabled = item.status == "IN_PROGRESS", + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.primary, + ), + shape = RoundedCornerShape(14.dp), + ) { + Icon(Icons.Outlined.EditNote, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Edit", maxLines = 1) + } + Button( + onClick = onComplete, + enabled = item.status == "IN_PROGRESS", + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 8.dp), + ) { + Icon(Icons.Outlined.WaterDrop, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Selesaikan", + maxLines = 1, + style = MaterialTheme.typography.labelLarge.copy(fontSize = 13.sp), + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WashingFormScreen( + state: WashingUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onSelectLot: (String) -> Unit, + onSelectPlace: (String) -> Unit, + onUpdateCost: (String) -> Unit, + onUpdateDuration: (String) -> Unit, + onSave: () -> Unit, +) { + val title = if (state.screen == WashingScreen.Edit) "Edit Washing" else "Buat Washing" + val selectedLot = remember(state.selectableLots, state.selectedLotId) { + state.selectableLots.find { it.id == state.selectedLotId } + } + val selectedPlace = remember(state.washingPlaces, state.selectedWashingPlaceId) { + state.washingPlaces.find { it.id == state.selectedWashingPlaceId } + } + val context = androidx.compose.ui.platform.LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var lotQuery by remember(state.selectedLotId, selectedLot?.lotCode) { + mutableStateOf(selectedLot?.lotCode.orEmpty()) + } + var placeExpanded by remember { mutableStateOf(false) } + var scanDialogOpen by remember { mutableStateOf(false) } + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + hasCameraPermission = granted + if (granted) { + scanDialogOpen = true + } + } + val filteredLots = remember(state.selectableLots, lotQuery) { + val query = lotQuery.trim().lowercase() + if (query.isBlank()) { + emptyList() + } else { + state.selectableLots.filter { + it.lotCode.lowercase().contains(query) || + it.supplier.lowercase().contains(query) || + it.grade.lowercase().contains(query) || + it.location.lowercase().contains(query) || + it.warehouse.lowercase().contains(query) + }.take(8) + } + } + val isSearchingLots = remember(lotQuery, selectedLot?.lotCode) { + val query = lotQuery.trim() + query.isNotBlank() && !query.equals(selectedLot?.lotCode.orEmpty(), ignoreCase = true) + } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = title, onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Pilih Lot", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = lotQuery, + onValueChange = { lotQuery = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Cari Lot") }, + placeholder = { Text("Kode lot / supplier / grade") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + IconButton( + onClick = { + if (hasCameraPermission) { + scanDialogOpen = true + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + ) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan barcode lot") + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + if (selectedLot != null) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + selectedLot.lotCode, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${selectedLot.supplier} • ${selectedLot.grade} • ${formatQuantity(selectedLot.availableQty)} ${selectedLot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f), + ) + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary) + } + } + } + when { + state.selectableLots.isEmpty() -> EmptyStateCard("Tidak ada lot yang siap diproses washing.") + lotQuery.isBlank() -> EmptyStateCard("Ketik kata kunci atau scan barcode untuk mencari lot.") + selectedLot != null && !isSearchingLots -> {} + filteredLots.isEmpty() -> EmptyStateCard("Tidak ada lot yang cocok dengan pencarian.") + else -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + filteredLots.forEach { lot -> + Surface( + shape = RoundedCornerShape(16.dp), + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + 1.dp, + if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { + onSelectLot(lot.id) + lotQuery = lot.lotCode + }, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + lot.lotCode, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + "${formatQuantity(lot.availableQty)} ${lot.unitCode}", + style = MaterialTheme.typography.bodySmall, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + ) + } + Text( + "${lot.supplier} • ${lot.grade}", + style = MaterialTheme.typography.bodySmall, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${lot.warehouse} • ${lot.location}", + style = MaterialTheme.typography.bodySmall, + color = if (lot.id == state.selectedLotId) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.86f) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + Divider(color = MaterialTheme.colorScheme.outlineVariant) + Text("Tempat Cuci", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + if (state.washingPlaces.isEmpty()) { + EmptyStateCard("Belum ada tempat cuci aktif.") + } else { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { placeExpanded = true }, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Tempat Cuci", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + selectedPlace?.name ?: "Pilih tempat cuci", + color = if (selectedPlace != null) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selectedPlace != null) FontWeight.SemiBold else FontWeight.Normal, + ) + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } + DropdownMenu( + expanded = placeExpanded, + onDismissRequest = { placeExpanded = false }, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + state.washingPlaces.forEach { place -> + DropdownMenuItem( + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(place.name, fontWeight = FontWeight.SemiBold) + if (!place.code.isNullOrBlank()) { + Text( + place.code.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onSelectPlace(place.id) + placeExpanded = false + }, + ) + } + } + } + } + OutlinedTextField( + value = state.washingCost, + onValueChange = onUpdateCost, + modifier = Modifier.fillMaxWidth(), + label = { Text("Biaya Washing") }, + placeholder = { Text("Contoh: 50000") }, + leadingIcon = { Icon(Icons.Outlined.ReceiptLong, contentDescription = null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + OutlinedTextField( + value = state.durationHours, + onValueChange = onUpdateDuration, + modifier = Modifier.fillMaxWidth(), + label = { Text("Durasi (jam)") }, + placeholder = { Text("24") }, + leadingIcon = { Icon(Icons.Outlined.WaterDrop, contentDescription = null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + } + } + } + + item { + Button( + onClick = onSave, + enabled = !state.isSaving, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.Outlined.EditNote, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (state.screen == WashingScreen.Edit) "Simpan Perubahan" else "Mulai Washing") + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } + + if (scanDialogOpen) { + Dialog(onDismissRequest = { scanDialogOpen = false }) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Scan Barcode Lot", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + IconButton(onClick = { scanDialogOpen = false }) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Tutup") + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.inverseSurface), + ) { + if (hasCameraPermission) { + CameraScannerPreview( + lifecycleOwner = lifecycleOwner, + modifier = Modifier.matchParentSize(), + onCodeDetected = { code -> + val matchedLot = state.selectableLots.firstOrNull { + it.lotCode.equals(code.trim(), ignoreCase = true) + } + lotQuery = code.trim() + matchedLot?.let { onSelectLot(it.id) } + scanDialogOpen = false + }, + ) + } + } + Text( + "Arahkan kamera ke barcode atau QR lot. Jika cocok, lot akan dipilih otomatis.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun WashingCompleteScreen( + state: WashingUiState, + modifier: Modifier = Modifier, + onBack: () -> Unit, + onUpdateAfterQty: (String) -> Unit, + onSelectGrade: (String?) -> Unit, + onSelectWarehouse: (String) -> Unit, + onSelectWarehouseLocation: (String?) -> Unit, + onComplete: () -> Unit, +) { + val selectedItem = state.washings.find { it.id == state.selectedWashingId } + val selectedWarehouse = state.warehouses.find { it.id == state.selectedWarehouseId } + + LazyColumn( + modifier = modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BackHeader(title = "Selesaikan Washing", onBack = onBack) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + selectedItem?.let { item -> + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column { + Text("NO WASHING", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(item.washingNo, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = item.status) + } + LotMetaCell("LOT", item.lot.lotCode) + Text( + "${item.beforeWarehouseName}${item.beforeLocationName?.let { " • $it" } ?: ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + item { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = state.afterQty, + onValueChange = onUpdateAfterQty, + modifier = Modifier.fillMaxWidth(), + label = { Text("After Qty") }, + placeholder = { Text("Contoh: 2.5") }, + leadingIcon = { Icon(Icons.Outlined.Inventory2, contentDescription = null) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + shape = RoundedCornerShape(16.dp), + ) + Text("Grade Hasil", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OptionChipGroup( + items = listOf("" to "Tetap") + state.grades.map { it.id to it.name }, + selectedId = state.selectedGradeId ?: "", + onSelect = { id -> onSelectGrade(id.ifBlank { null }) }, + emptyLabel = "Belum ada grade aktif.", + ) + Divider(color = MaterialTheme.colorScheme.outlineVariant) + Text("Gudang Tujuan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OptionChipGroup( + items = state.warehouses.map { it.id to it.name }, + selectedId = state.selectedWarehouseId, + onSelect = onSelectWarehouse, + emptyLabel = "Belum ada gudang tujuan.", + ) + Text("Lokasi Tujuan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OptionChipGroup( + items = listOf("" to "Tanpa lokasi") + (selectedWarehouse?.locations ?: emptyList()).map { it.id to it.name }, + selectedId = state.selectedWarehouseLocationId ?: "", + onSelect = { id -> onSelectWarehouseLocation(id.ifBlank { null }) }, + emptyLabel = "Gudang ini tidak memiliki lokasi aktif.", + ) + } + } + } + + item { + Button( + onClick = onComplete, + enabled = !state.isCompleting, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + ) { + if (state.isCompleting) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) + } else { + Icon(Icons.Outlined.WaterDrop, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Selesaikan Washing") + } + } + } + + item { Spacer(modifier = Modifier.height(20.dp)) } + } +} + +@Composable +private fun BackHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Kembali") + } + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } +} + +@Composable +private fun OptionChipGroup( + items: List>, + selectedId: String?, + onSelect: (String) -> Unit, + emptyLabel: String, +) { + if (items.isEmpty()) { + EmptyStateCard(emptyLabel) + return + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items.forEach { (id, label) -> + Surface( + shape = RoundedCornerShape(16.dp), + color = if (selectedId == id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke( + 1.dp, + if (selectedId == id) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant, + ), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onSelect(id) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + modifier = Modifier.weight(1f), + color = if (selectedId == id) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + if (selectedId == id) { + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + } + } + } + } +} + +@Composable +private fun LotInventoryScreen( + state: LotsUiState, + modifier: Modifier = Modifier, + onTabChanged: (String) -> Unit, + onRefresh: () -> Unit, + onQueryChanged: (String) -> Unit, + onOpenLotDetail: (String) -> Unit, +) { + val filteredLots = remember(state.lots, state.query) { + val query = state.query.trim().lowercase() + if (query.isBlank()) state.lots else state.lots.filter { + it.lotCode.lowercase().contains(query) || + it.grade.lowercase().contains(query) || + it.supplier.lowercase().contains(query) || + it.location.lowercase().contains(query) || + it.warehouse.lowercase().contains(query) + } + } + + LazyColumn( + modifier = modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + LotsModeTabs(activeTab = "inventory", onTabChanged = onTabChanged) + Spacer(modifier = Modifier.height(14.dp)) + Text("Daftar Lot", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + Text( + "Cari dan buka detail lot aktif dari gudang atau proses QC.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + item { + OutlinedTextField( + value = state.query, + onValueChange = onQueryChanged, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + IconButton(onClick = onRefresh) { + if (state.isLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh lots") + } + } + }, + shape = RoundedCornerShape(16.dp), + singleLine = true, + label = { Text("Cari Kode Lot / Grade / Lokasi") }, + placeholder = { Text("Contoh: LOT-260518 atau Mangkok") }, + ) + } + + if (!state.inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = state.inlineError) } + } + + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = {}, label = { Text("${filteredLots.size} lot tampil") }) + AssistChip(onClick = {}, label = { Text("${state.lots.count { it.status == "ACTIVE" }} active") }) + } + } + + if (state.isLoading && state.lots.isEmpty()) { + item { LoadingCard("Memuat daftar lot...") } + } else if (filteredLots.isEmpty()) { + item { EmptyStateCard("Tidak ada lot yang cocok dengan pencarian.") } + } else { + items(filteredLots, key = { it.id }) { lot -> + LotInventoryCard(lot = lot, onClick = { onOpenLotDetail(lot.id) }) + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun LotScanScreen( + role: String, + state: LotsUiState, + modifier: Modifier = Modifier, + onTabChanged: (String) -> Unit, + onScanInputChanged: (String) -> Unit, + onScan: () -> Unit, + onCameraScan: (String) -> Unit, + onOpenRecentScan: (LotScanResult) -> Unit, +) { + val context = androidx.compose.ui.platform.LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + hasCameraPermission = granted + } + var lastCameraCode by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + LaunchedEffect(state.isScanning) { + if (!state.isScanning) { + lastCameraCode = null + } + } + + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + LotsModeTabs(activeTab = "scan", onTabChanged = onTabChanged) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .clickable { onTabChanged("inventory") }, + ) { + Text( + text = "Tutup", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + Spacer(modifier = Modifier.height(14.dp)) + } + } + + item { + Surface( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.inverseSurface, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3f) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.inverseSurface), + contentAlignment = Alignment.Center, + ) { + if (hasCameraPermission) { + CameraScannerPreview( + lifecycleOwner = lifecycleOwner, + modifier = Modifier.matchParentSize(), + onCodeDetected = { code -> + if (!state.isScanning && code.isNotBlank() && code != lastCameraCode) { + lastCameraCode = code + onCameraScan(code) + } + }, + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Outlined.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(42.dp), + ) + Text( + "Izin kamera diperlukan untuk scan lot", + color = MaterialTheme.colorScheme.surface, + fontWeight = FontWeight.SemiBold, + ) + Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) { + Text("Aktifkan Kamera") + } + } + } + Box( + modifier = Modifier + .size(240.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Color.Transparent), + ) { + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + ) + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .height(2.dp) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.75f)), + ) + CornerMarker(Modifier.align(Alignment.TopStart)) + CornerMarker(Modifier.align(Alignment.TopEnd), flippedX = true) + CornerMarker(Modifier.align(Alignment.BottomStart), flippedY = true) + CornerMarker(Modifier.align(Alignment.BottomEnd), flippedX = true, flippedY = true) + } + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Scanner siap untuk validasi lot", + color = MaterialTheme.colorScheme.surface, + fontWeight = FontWeight.SemiBold, + ) + Text( + if (hasCameraPermission) "Arahkan kamera ke QR atau barcode lot." + else "Izin kamera belum diberikan. Gunakan input manual dahulu.", + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + item { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Input Manual Kode Lot", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = state.scanInput, + onValueChange = onScanInputChanged, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Outlined.QrCode2, contentDescription = null) }, + trailingIcon = { + Button( + onClick = onScan, + enabled = !state.isScanning, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 8.dp), + ) { + if (state.isScanning) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Cari") + } + } + }, + label = { Text("Masukkan kode lot") }, + placeholder = { Text("Contoh: LOT-260518-DIMA-018") }, + ) + AssistChip(onClick = {}, label = { Text("Role $role") }) + } + } + + if (!state.inlineError.isNullOrBlank()) { + item { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + InlineErrorCard(message = state.inlineError) + } + } + } + + item { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Riwayat Scan Terakhir", style = MaterialTheme.typography.titleMedium) + Text( + "${state.recentScans.size} item", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + if (state.recentScans.isEmpty()) { + EmptyStateCard("Belum ada hasil scan di sesi ini.") + } else { + state.recentScans.forEach { result -> + RecentScanCard(result = result, onClick = { onOpenRecentScan(result) }) + } + } + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } +} + +@Composable +private fun CameraScannerPreview( + lifecycleOwner: androidx.lifecycle.LifecycleOwner, + modifier: Modifier = Modifier, + onCodeDetected: (String) -> Unit, +) { + val context = androidx.compose.ui.platform.LocalContext.current + val previewView = remember { + PreviewView(context).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + } + } + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + + DisposableEffect(lifecycleOwner) { + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().apply { + surfaceProvider = previewView.surfaceProvider + } + val scanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats( + com.google.mlkit.vision.barcode.common.Barcode.FORMAT_QR_CODE, + com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128, + com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39, + com.google.mlkit.vision.barcode.common.Barcode.FORMAT_EAN_13, + com.google.mlkit.vision.barcode.common.Barcode.FORMAT_DATA_MATRIX, + ) + .build(), + ) + val analyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { + setAnalyzer(analysisExecutor) { imageProxy -> + processImageProxy( + imageProxy = imageProxy, + scanner = scanner, + onCodeDetected = onCodeDetected, + ) + } + } + + runCatching { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analyzer, + ) + } + + onDispose { + cameraProvider.unbindAll() + scanner.close() + analysisExecutor.shutdown() + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier, + ) +} + +@Composable +private fun LotDetailScreen( + detail: LotDetailData?, + isLoading: Boolean, + inlineError: String?, + modifier: Modifier = Modifier, + onBack: () -> Unit, +) { + when { + isLoading && detail == null -> { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + LoadingCard("Memuat detail lot...") + } + } + + detail == null -> { + Box(modifier = modifier.padding(20.dp), contentAlignment = Alignment.Center) { + EmptyStateCard("Detail lot belum tersedia.") + } + } + + else -> { + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets(0.dp), + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = onBack) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Kembali") + } + Text("Detail Lot", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + } + } + + if (!inlineError.isNullOrBlank()) { + item { InlineErrorCard(message = inlineError) } + } + + item { LotHeaderCard(detail) } + item { LotQrCard(detail) } + item { LotTimelineSection(detail) } + item { LotInternalNotes(detail) } + item { Spacer(modifier = Modifier.height(8.dp)) } + } + } + } + } +} + +@Composable +private fun InlineErrorCard(message: String) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.10f), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Pembaruan terakhir tidak sempurna", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun LotInventoryCard( + lot: LotItem, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("KODE LOT", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text(lot.lotCode, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = lot.status) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("SUPPLIER", lot.supplier) + LotMetaCell("GRADE", lot.grade) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("QUANTITY", "${formatQuantity(lot.availableQty)} ${lot.unitCode}", emphasize = true) + Column(horizontalAlignment = Alignment.End) { + Text("LOKASI", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Outlined.LocationOn, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(14.dp)) + Text(lot.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface) + } + } + } + } + } +} + +@Composable +private fun LotHeaderCard(detail: LotDetailData) { + val summary = detail.summaryCardOrFallback() + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 3.dp, + ) { + Column(modifier = Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Kode Lot", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(summary.lotCode, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary) + } + StatusPill(status = summary.status) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("SUPPLIER", summary.supplierName ?: "-") + LotMetaCell("KUANTITAS", "${formatQuantity(summary.availableQty)} ${summary.unitCode.orEmpty()}", alignEnd = true, emphasize = true) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + LotMetaCell("LOKASI SAAT INI", listOfNotNull(summary.warehouseName, summary.warehouseLocationName).joinToString(" • ").ifBlank { "-" }) + LotMetaCell("GRADE", summary.grade ?: "-", alignEnd = true) + } + } + } +} + +@Composable +private fun LotQrCard(detail: LotDetailData) { + val summary = detail.summaryCardOrFallback() + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.QrCode2, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(30.dp)) + Column { + Text("Verifikasi Identitas Lot", color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.Bold) + Text(summary.qrCodeValue ?: summary.lotCode, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f), style = MaterialTheme.typography.bodySmall) + } + } + Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + } +} + +@Composable +private fun LotTimelineSection(detail: LotDetailData) { + val events = buildLotTimeline(detail) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Riwayat Pergerakan", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + ) { + Column(modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp)) { + events.forEachIndexed { index, event -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (index == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant), + ) + if (index != events.lastIndex) { + Box( + modifier = Modifier + .width(2.dp) + .height(42.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .padding(bottom = if (index == events.lastIndex) 6.dp else 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(event.timestampLabel, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(event.title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + if (event.description.isNotBlank()) { + Text(event.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } + } +} + +@Composable +private fun LotInternalNotes(detail: LotDetailData) { + val procurement = detail.procurement + val notes = buildList { + procurement?.purchaseNo?.let { add("Referensi pembelian: $it") } + procurement?.receiptNo?.let { add("Referensi receipt: $it") } + if (detail.mobileActions?.canAdjust == true) add("Role aktif dapat melakukan stock adjustment untuk lot ini.") + if (detail.mobileActions?.canMix == true) add("Lot ini eligible untuk mixing.") + if (detail.mobileActions?.canRegrade == true) add("Lot ini eligible untuk regrade.") + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("CATATAN INTERNAL", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + notes.joinToString(" ") .ifBlank { "Belum ada catatan internal tambahan untuk lot ini." }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun RecentScanCard( + result: LotScanResult, + onClick: () -> Unit, +) { + val summary = result.payload.summaryCard + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Inventory2, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(summary.lotCode, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold) + Text( + "${summary.warehouseLocationName ?: "-"} • ${formatQuantity(summary.availableQty)} ${summary.unitCode.orEmpty()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.End) { + StatusPill(status = summary.status) + Spacer(modifier = Modifier.height(4.dp)) + Text(relativeTimeLabel(result.scannedAtMillis), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun LotsModeTabs( + activeTab: String, + onTabChanged: (String) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LotsModeChip( + label = "Inventory", + active = activeTab == "inventory", + onClick = { onTabChanged("inventory") }, + ) + LotsModeChip( + label = "Scan", + active = activeTab == "scan", + onClick = { onTabChanged("scan") }, + ) + } +} + +@Composable +private fun LotsModeChip( + label: String, + active: Boolean, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.clip(RoundedCornerShape(999.dp)).clickable(onClick = onClick), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + color = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +private fun StatusPill(status: String) { + val (bg, fg) = when (status.uppercase()) { + "ACTIVE" -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + "PROCESSING" -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.primary + "QC_PENDING", "HOLD" -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.16f) to MaterialTheme.colorScheme.tertiary + "EXPORT_READY" -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) to MaterialTheme.colorScheme.primary + "DEPLETED", "CLOSED", "REJECT" -> MaterialTheme.colorScheme.error.copy(alpha = 0.12f) to MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant + } + Surface(shape = RoundedCornerShape(999.dp), color = bg) { + Text( + text = prettifyStatus(status), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = fg, + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +private fun LotMetaCell( + label: String, + value: String, + alignEnd: Boolean = false, + emphasize: Boolean = false, +) { + Column(horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline) + Text( + value, + style = MaterialTheme.typography.bodySmall, + color = if (emphasize) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal, + textAlign = if (alignEnd) TextAlign.End else TextAlign.Start, + ) + } +} + +@Composable +private fun FooterActionButton( + modifier: Modifier = Modifier, + icon: ImageVector, + label: String, + primary: Boolean, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = if (primary) MaterialTheme.colorScheme.primary else Color.Transparent, + border = if (primary) null else androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + ) { + Column( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(icon, contentDescription = null, tint = if (primary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = if (primary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun LoadingCard(label: String) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.4.dp) + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun EmptyStateCard(label: String) { + Surface(shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceContainerLowest) { + Text( + text = label, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun CornerMarker( + modifier: Modifier = Modifier, + flippedX: Boolean = false, + flippedY: Boolean = false, +) { + val markerColor = MaterialTheme.colorScheme.primary + Canvas( + modifier = modifier + .size(34.dp) + .padding(4.dp), + ) { + val stroke = 4.dp.toPx() + val arm = size.minDimension * 0.62f + val left = 0f + val right = size.width + val top = 0f + val bottom = size.height + val x = if (flippedX) right else left + val y = if (flippedY) bottom else top + val horizontalEnd = if (flippedX) x - arm else x + arm + val verticalEnd = if (flippedY) y - arm else y + arm + + drawLine( + color = markerColor, + start = Offset(x, y), + end = Offset(horizontalEnd, y), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + color = markerColor, + start = Offset(x, y), + end = Offset(x, verticalEnd), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } +} + +@Composable +private fun ModulePlaceholderScreen( + module: String, + role: String, + availableModules: List, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(26.dp), + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shadowElevation = 4.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = moduleIcon(module), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(34.dp), + ) + } + Text( + text = moduleLabel(module), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Modul ini sudah dikenali dari bootstrap untuk role $role dan siap diisi implementasi layar detail berikutnya.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + AssistChip( + onClick = {}, + label = { + Text("${availableModules.size} modul aktif") + }, + ) + } + } + } +} + +private fun buildQuickActions(role: String, modules: List): List { + if (role == "OWNER") { + return buildBottomBarItems(role = role, currentModule = "dashboard", modules = modules) + .filter { it != "dashboard" } + .map { + QuickAction( + module = it, + label = moduleLabel(it), + iconName = it, + ) + } + } + val source = if (modules.isNotEmpty()) orderModulesForRole(role, modules) else roleDefaults(role) + return source + .filter { it != "dashboard" } + .map { + QuickAction( + module = it, + label = moduleLabel(it), + iconName = it, + ) + } + .take(8) + .ifEmpty { + roleDefaults(role).map { + QuickAction(module = it, label = moduleLabel(it), iconName = it) + } + } +} + +private fun buildBottomBarItems(role: String, currentModule: String, modules: List): List { + val normalized = orderModulesForRole(role, modules).filter { it != "dashboard" }.distinct() + val baseItems = mutableListOf("dashboard") + baseItems += normalized.take(3) + + if (currentModule != "dashboard" && currentModule !in baseItems && normalized.contains(currentModule)) { + if (baseItems.size >= 4) { + baseItems[baseItems.lastIndex] = currentModule + } else { + baseItems += currentModule + } + } + + return baseItems.distinct() +} + +private fun roleDefaults(role: String): List = when (role) { + "WAREHOUSE" -> listOf("lots", "stock_adjustments", "washing", "purchases") + "QC" -> listOf("lots", "lot_transformations", "washing", "stock_adjustments") + "SALES" -> listOf("lots", "sales_regular", "sales_jit", "consignments") + "PURCHASING" -> listOf("purchases", "fund_requests", "purchase_analyses", "purchase_realizations") + "OWNER" -> listOf( + "purchases", + "purchase_analyses", + "purchase_realizations", + ) + else -> listOf("dashboard", "lots") +} + +private val hiddenMobileModules = setOf("receipts") + +private fun orderModulesForRole(role: String, modules: List): List { + val normalized = modules + .filterNot { it in hiddenMobileModules } + .distinct() + val priority = roleDefaults(role) + .filter { it != "dashboard" && it !in hiddenMobileModules } + val activePriority = priority.filter { it in normalized } + val extras = normalized.filter { it != "dashboard" && it !in activePriority } + return buildList { + add("dashboard") + addAll(activePriority) + addAll(extras) + }.distinct() +} + +private fun moduleLabel(module: String): String = when (module) { + "dashboard" -> "Dashboard" + "lots" -> "Stok" + "receipts" -> "Terima" + "washing" -> "Washing" + "stock_adjustments" -> "Adjust" + "lot_transformations" -> "Sortasi" + "sales_regular" -> "Sales" + "sales_jit" -> "JIT" + "consignments" -> "Titip Jual" + "purchases" -> "Beli" + "fund_requests" -> "Dana" + "purchase_analyses" -> "Analisis" + "purchase_realizations" -> "Realisasi" + else -> module.replace('_', ' ').replaceFirstChar { it.uppercase() } +} + +private fun moduleIcon(module: String): ImageVector = when (module) { + "dashboard" -> Icons.Outlined.Dashboard + "lots" -> Icons.Outlined.QrCodeScanner + "receipts" -> Icons.Outlined.ReceiptLong + "washing" -> Icons.Outlined.WaterDrop + "stock_adjustments" -> Icons.Outlined.ShowChart + "lot_transformations" -> Icons.Outlined.AccountTree + "sales_regular", "sales_jit", "consignments" -> Icons.Outlined.LocalShipping + "purchases", "fund_requests" -> Icons.Outlined.Inventory2 + "purchase_analyses", "purchase_realizations" -> Icons.Outlined.ContentPasteSearch + else -> Icons.Outlined.Settings +} + +private fun QuickAction.icon() = moduleIcon(iconName) + +@Composable +private fun alertColor(status: String): Color { + return when (status) { + "LOW_STOCK" -> MaterialTheme.colorScheme.error + "ON_HOLD" -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } +} + +private fun formatQuantity(value: Double): String { + return if (value % 1.0 == 0.0) { + value.roundToInt().toString() + } else { + String.format("%.1f", value) + } +} + +private fun formatCurrency(value: Double): String { + return NumberFormat.getCurrencyInstance(Locale("id", "ID")).format(value) +} + +private data class LotTimelineEvent( + val timestampLabel: String, + val title: String, + val description: String = "", +) + +private fun buildLotTimeline(detail: LotDetailData): List { + val summary = detail.summaryCardOrFallback() + val timeline = mutableListOf() + + summary.receivedAt?.let { + timeline += LotTimelineEvent( + timestampLabel = formatApiDateTime(it), + title = "Lot diterima di ${summary.warehouseLocationName ?: summary.warehouseName ?: "gudang"}", + description = listOfNotNull(summary.supplierName, summary.purchaseNo).joinToString(" • "), + ) + } + + summary.receiptDate?.let { + timeline += LotTimelineEvent( + timestampLabel = formatApiDateTime(it), + title = "Receipt tercatat", + description = summary.receiptNo ?: "Receipt mobile / web", + ) + } + + summary.purchaseDate?.let { + timeline += LotTimelineEvent( + timestampLabel = formatApiDateTime(it), + title = "Pembelian dibuat", + description = summary.purchaseNo ?: "Sumber pembelian", + ) + } + + if (timeline.isEmpty()) { + timeline += LotTimelineEvent( + timestampLabel = "Belum tersedia", + title = "Riwayat lot belum tersedia", + description = "Endpoint detail saat ini belum mengembalikan movement history lengkap.", + ) + } + + return timeline +} + +private fun prettifyStatus(status: String): String { + return status.lowercase() + .split('_') + .joinToString(" ") { it.replaceFirstChar { ch -> ch.uppercase() } } +} + +private fun formatApiDateTime(value: String): String { + return runCatching { + val input = when { + value.contains('T') -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US) + else -> SimpleDateFormat("yyyy-MM-dd", Locale.US) + }.apply { timeZone = TimeZone.getTimeZone("UTC") } + val output = SimpleDateFormat("dd MMM yyyy, HH:mm", Locale("id", "ID")) + val date = input.parse(value) ?: return value + output.format(date) + }.getOrElse { value } +} + +private fun relativeTimeLabel(timestampMillis: Long): String { + val diff = (System.currentTimeMillis() - timestampMillis).coerceAtLeast(0L) + val minutes = diff / 60_000L + return when { + minutes < 1 -> "baru saja" + minutes < 60 -> "$minutes mnt lalu" + minutes < 1_440 -> "${minutes / 60} jam lalu" + else -> "${minutes / 1_440} hari lalu" + } +} + +private fun LotDetailData.summaryCardOrFallback(): id.abelbirdnest.mobile.data.LotSummaryCard { + return summaryCard ?: id.abelbirdnest.mobile.data.LotSummaryCard( + lotCode = lot.lotCode, + sourceType = lot.sourceType, + status = lot.status, + grade = lot.grade?.name, + supplierName = lot.supplier?.name, + warehouseName = lot.warehouse?.name, + warehouseLocationName = lot.location?.name, + availableQty = lot.availableQty, + originalQty = lot.originalQty, + reservedQty = lot.reservedQty, + damagedQty = lot.damagedQty, + shrinkageQty = lot.shrinkageQty, + unitCode = lot.unitCode, + unitCost = lot.unitCost, + estimatedValue = lot.availableQty * lot.unitCost, + purchaseNo = lot.purchase?.purchaseNo, + purchaseDate = lot.purchase?.purchaseDate, + receiptNo = lot.receipt?.receiptNo, + receiptDate = lot.receipt?.receiptDate, + receivedAt = lot.receivedAt, + qrCodeValue = lot.labels.firstOrNull { it.type.equals("QR", ignoreCase = true) }?.value, + barcodeValue = lot.labels.firstOrNull { it.type.equals("BARCODE", ignoreCase = true) }?.value, + parentLotCode = lot.parentLot?.lotCode, + childLotCount = lot.childLots.size, + transformationCount = 0, + ) +} + +private fun processImageProxy( + imageProxy: androidx.camera.core.ImageProxy, + scanner: com.google.mlkit.vision.barcode.BarcodeScanner, + onCodeDetected: (String) -> Unit, +) { + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return + } + + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + barcodes.firstNotNullOfOrNull { it.rawValue?.trim()?.takeIf(String::isNotBlank) }?.let(onCodeDetected) + } + .addOnCompleteListener { + imageProxy.close() + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/ui/MainViewModel.kt b/app/src/main/java/id/abelbirdnest/mobile/ui/MainViewModel.kt new file mode 100644 index 0000000..8a73921 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/ui/MainViewModel.kt @@ -0,0 +1,3231 @@ +package id.abelbirdnest.mobile.ui + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import id.abelbirdnest.mobile.data.AdjustmentReason +import id.abelbirdnest.mobile.data.CodeLabelOption +import id.abelbirdnest.mobile.data.CompleteWashingPayload +import id.abelbirdnest.mobile.data.ConsignmentBootstrapData +import id.abelbirdnest.mobile.data.ConsignmentCloseLinePayload +import id.abelbirdnest.mobile.data.ConsignmentDetail +import id.abelbirdnest.mobile.data.ConsignmentLineDetail +import id.abelbirdnest.mobile.data.ConsignmentListItem +import id.abelbirdnest.mobile.data.DashboardBundle +import id.abelbirdnest.mobile.data.FundRequestAgentOption +import id.abelbirdnest.mobile.data.FundRequestBankAccountOption +import id.abelbirdnest.mobile.data.FundRequestListItem +import id.abelbirdnest.mobile.data.FundRequestsBootstrapData +import id.abelbirdnest.mobile.data.JitSaleCloseLinePayload +import id.abelbirdnest.mobile.data.JitSaleClosePayload +import id.abelbirdnest.mobile.data.JitSaleDetail +import id.abelbirdnest.mobile.data.JitSaleLineDetail +import id.abelbirdnest.mobile.data.JitSaleListItem +import id.abelbirdnest.mobile.data.LookupRecord +import id.abelbirdnest.mobile.data.LotDetailData +import id.abelbirdnest.mobile.data.LotItem +import id.abelbirdnest.mobile.data.LotScanResult +import id.abelbirdnest.mobile.data.LotTransformationCreateInputPayload +import id.abelbirdnest.mobile.data.LotTransformationCreateOutputPayload +import id.abelbirdnest.mobile.data.LotTransformationCreatePayload +import id.abelbirdnest.mobile.data.MobileRepository +import id.abelbirdnest.mobile.data.PurchaseAnalysisDetail +import id.abelbirdnest.mobile.data.PurchaseAnalysisListItem +import id.abelbirdnest.mobile.data.PurchaseCreateLinePayload +import id.abelbirdnest.mobile.data.PurchaseCreatePayload +import id.abelbirdnest.mobile.data.PurchaseDetail +import id.abelbirdnest.mobile.data.PurchaseListItem +import id.abelbirdnest.mobile.data.PurchaseRealizationDetail +import id.abelbirdnest.mobile.data.PurchaseRealizationListItem +import id.abelbirdnest.mobile.data.RegularSaleCloseLinePayload +import id.abelbirdnest.mobile.data.RegularSaleClosePayload +import id.abelbirdnest.mobile.data.RegularSaleDetail +import id.abelbirdnest.mobile.data.RegularSaleLineDetail +import id.abelbirdnest.mobile.data.RegularSaleListItem +import id.abelbirdnest.mobile.data.ReceiptBootstrapData +import id.abelbirdnest.mobile.data.ReceiptCreateLinePayload +import id.abelbirdnest.mobile.data.ReceiptCreatePayload +import id.abelbirdnest.mobile.data.ReceiptDetail +import id.abelbirdnest.mobile.data.ReceiptDetailLine +import id.abelbirdnest.mobile.data.ReceiptListItem +import id.abelbirdnest.mobile.data.ReceiptPurchaseLineOption +import id.abelbirdnest.mobile.data.ReceiptPurchaseOption +import id.abelbirdnest.mobile.data.LotTransformationDetail +import id.abelbirdnest.mobile.data.LotTransformationListItem +import id.abelbirdnest.mobile.data.StockAdjustmentCreatePayload +import id.abelbirdnest.mobile.data.StockAdjustmentListItem +import id.abelbirdnest.mobile.data.UnitLookup +import id.abelbirdnest.mobile.data.WarehouseLookup +import id.abelbirdnest.mobile.data.WashingCreatePayload +import id.abelbirdnest.mobile.data.WashingListItem +import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +import kotlin.math.roundToInt + +data class MainUiState( + val isCheckingSession: Boolean = true, + val isSubmitting: Boolean = false, + val isRefreshing: Boolean = false, + val isAuthenticated: Boolean = false, + val dashboard: DashboardBundle? = null, + val currentModule: String = "dashboard", + val lotsState: LotsUiState = LotsUiState(), + val salesRegularState: SalesRegularUiState = SalesRegularUiState(), + val salesJitState: SalesJitUiState = SalesJitUiState(), + val consignmentsState: ConsignmentsUiState = ConsignmentsUiState(), + val fundRequestsState: FundRequestsUiState = FundRequestsUiState(), + val purchasesState: PurchasesUiState = PurchasesUiState(), + val purchaseAnalysesState: PurchaseAnalysesUiState = PurchaseAnalysesUiState(), + val purchaseRealizationsState: PurchaseRealizationsUiState = PurchaseRealizationsUiState(), + val lotTransformationsState: LotTransformationsUiState = LotTransformationsUiState(), + val stockAdjustmentsState: StockAdjustmentsUiState = StockAdjustmentsUiState(), + val washingState: WashingUiState = WashingUiState(), + val receiptsState: ReceiptsUiState = ReceiptsUiState(), + val errorMessage: String? = null, +) + +data class LotsUiState( + val isLoading: Boolean = false, + val isScanning: Boolean = false, + val isLoadingDetail: Boolean = false, + val query: String = "", + val lots: List = emptyList(), + val selectedLotId: String? = null, + val lotDetail: LotDetailData? = null, + val recentScans: List = emptyList(), + val scanInput: String = "", + val inlineError: String? = null, +) + +data class WashingUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val isCompleting: Boolean = false, + val washings: List = emptyList(), + val washingPlaces: List = emptyList(), + val grades: List = emptyList(), + val warehouses: List = emptyList(), + val selectableLots: List = emptyList(), + val screen: WashingScreen = WashingScreen.List, + val selectedWashingId: String? = null, + val selectedLotId: String? = null, + val selectedWashingPlaceId: String? = null, + val washingCost: String = "", + val durationHours: String = "24", + val afterQty: String = "", + val selectedGradeId: String? = null, + val selectedWarehouseId: String? = null, + val selectedWarehouseLocationId: String? = null, + val inlineError: String? = null, +) + +data class StockAdjustmentsUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val items: List = emptyList(), + val reasons: List = emptyList(), + val selectableLots: List = emptyList(), + val selectedLotId: String? = null, + val selectedReasonId: String? = null, + val adjustmentDate: String = "", + val qtyChange: String = "", + val notes: String = "", + val inlineError: String? = null, +) + +data class PurchasesUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val isActing: Boolean = false, + val items: List = emptyList(), + val employees: List = emptyList(), + val units: List = emptyList(), + val grades: List = emptyList(), + val warehouses: List = emptyList(), + val screen: PurchasesScreen = PurchasesScreen.List, + val selectedPurchaseId: String? = null, + val selectedPurchase: PurchaseDetail? = null, + val purchaseDate: String = "", + val receivedAt: String = "", + val selectedEmployeeId: String? = null, + val selectedWarehouseId: String? = null, + val selectedWarehouseLocationId: String? = null, + val notes: String = "", + val lines: List = emptyList(), + val inlineError: String? = null, +) + +data class PurchaseLineFormState( + val selectedGradeId: String? = null, + val qtyOrdered: String = "", + val selectedUnitId: String? = null, + val unitPrice: String = "", + val unitCost: String = "", + val selectedWarehouseId: String? = null, + val selectedWarehouseLocationId: String? = null, + val notes: String = "", +) + +data class PurchaseAnalysesUiState( + val isLoading: Boolean = false, + val items: List = emptyList(), + val selectedPurchaseId: String? = null, + val selectedDetail: PurchaseAnalysisDetail? = null, + val inlineError: String? = null, +) + +data class PurchaseRealizationsUiState( + val isLoading: Boolean = false, + val items: List = emptyList(), + val selectedPurchaseId: String? = null, + val selectedDetail: PurchaseRealizationDetail? = null, + val inlineError: String? = null, +) + +data class SalesRegularUiState( + val isLoading: Boolean = false, + val isClosing: Boolean = false, + val items: List = emptyList(), + val selectedSaleId: String? = null, + val selectedSale: RegularSaleDetail? = null, + val closeDate: String = "", + val closeLines: List = emptyList(), + val inlineError: String? = null, +) + +data class RegularSaleCloseLineFormState( + val lineId: String, + val lotCode: String, + val qtyPlanned: Double, + val qtyActualSold: String, + val qtyReturned: String, + val sellingPriceActual: String, +) + +data class SalesJitUiState( + val isLoading: Boolean = false, + val isClosing: Boolean = false, + val items: List = emptyList(), + val selectedSaleId: String? = null, + val selectedSale: JitSaleDetail? = null, + val closeDate: String = "", + val closeLines: List = emptyList(), + val inlineError: String? = null, +) + +data class JitSaleCloseLineFormState( + val lineId: String, + val gradeName: String, + val qtyPlanned: Double, + val sellingPriceActual: String, +) + +data class ConsignmentsUiState( + val isLoading: Boolean = false, + val isClosing: Boolean = false, + val items: List = emptyList(), + val salesOptions: List = emptyList(), + val buyerOptions: List = emptyList(), + val lotOptions: List = emptyList(), + val selectedConsignmentId: String? = null, + val selectedConsignment: ConsignmentDetail? = null, + val selectedLineId: String? = null, + val closeDate: String = "", + val sellingPrice: String = "", + val qtySold: String = "", + val qtyReturned: String = "", + val salesCommission: String = "", + val inlineError: String? = null, +) + +data class FundRequestsUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val items: List = emptyList(), + val agents: List = emptyList(), + val companyBankAccounts: List = emptyList(), + val currencyCode: String = "IDR", + val transferType: String = "CAPITAL", + val referenceNo: String = "", + val selectedAgentId: String? = null, + val selectedAgentBankAccountId: String? = null, + val selectedCompanyBankAccountId: String? = null, + val amount: String = "", + val transferredAt: String = "", + val inlineError: String? = null, +) + +data class LotTransformationsUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val items: List = emptyList(), + val selectableLots: List = emptyList(), + val transformationTypes: List = emptyList(), + val remainderModes: List = emptyList(), + val processingLossModes: List = emptyList(), + val grades: List = emptyList(), + val warehouses: List = emptyList(), + val screen: LotTransformationsScreen = LotTransformationsScreen.List, + val selectedTransformationId: String? = null, + val selectedTransformation: LotTransformationDetail? = null, + val transformationType: String = "MIX", + val transformationDate: String = "", + val remainderMode: String? = null, + val processingLossMode: String? = null, + val notes: String = "", + val inputs: List = emptyList(), + val outputs: List = emptyList(), + val inlineError: String? = null, +) + +data class LotTransformationInputFormState( + val selectedLotId: String? = null, + val lotQuery: String = "", + val qtyUsed: String = "", + val notes: String = "", +) + +data class LotTransformationOutputFormState( + val selectedGradeId: String? = null, + val selectedWarehouseId: String? = null, + val selectedWarehouseLocationId: String? = null, + val qtyProduced: String = "", + val notes: String = "", +) + +data class ReceiptsUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val isGeneratingLots: Boolean = false, + val receipts: List = emptyList(), + val purchases: List = emptyList(), + val warehouses: List = emptyList(), + val screen: ReceiptsScreen = ReceiptsScreen.List, + val selectedReceiptId: String? = null, + val selectedReceipt: ReceiptDetail? = null, + val selectedPurchaseId: String? = null, + val receiptDate: String = "", + val notes: String = "", + val lines: List = emptyList(), + val inlineError: String? = null, +) + +data class ReceiptLineFormState( + val purchaseLineId: String, + val gradeId: String? = null, + val gradeName: String = "-", + val qtyOrdered: String = "0", + val qtyReceived: String = "0", + val qtyAccepted: String = "0", + val qtyRejected: String = "0", + val unitId: String, + val unitCode: String, + val unitCost: String = "0", + val warehouseId: String = "", + val warehouseLocationId: String = "", + val notes: String = "", +) + +enum class WashingScreen { + List, + Create, + Edit, + Complete, +} + +enum class ReceiptsScreen { + List, + Create, + Detail, +} + +enum class LotTransformationsScreen { + List, + Create, + Detail, +} + +enum class PurchasesScreen { + List, + Create, + Edit, + Detail, +} + +class MainViewModel(application: Application) : AndroidViewModel(application) { + private val repository = MobileRepository(application) + + var identity by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var uiState by mutableStateOf(MainUiState()) + private set + + init { + restoreSession() + } + + fun onIdentityChanged(value: String) { + identity = value + } + + fun onPasswordChanged(value: String) { + password = value + } + + fun login() { + if (identity.isBlank() || password.isBlank()) { + uiState = uiState.copy(errorMessage = "Email/username dan kata sandi wajib diisi.") + return + } + + viewModelScope.launch { + uiState = uiState.copy(isSubmitting = true, errorMessage = null) + try { + repository.login(identity.trim(), password) + loadDashboard(trigger = LoadTrigger.Login) + } catch (error: Throwable) { + uiState = uiState.copy( + isSubmitting = false, + isAuthenticated = false, + errorMessage = error.toReadableMessage(), + ) + } + } + } + + fun refreshDashboard() { + if (uiState.isRefreshing || uiState.isCheckingSession || uiState.isSubmitting) return + viewModelScope.launch { + loadDashboard(trigger = LoadTrigger.Refresh) + } + } + + fun logout() { + repository.logout() + password = "" + uiState = MainUiState( + isCheckingSession = false, + currentModule = "dashboard", + ) + } + + fun clearError() { + uiState = uiState.copy(errorMessage = null) + } + + fun selectModule(module: String) { + val dashboard = uiState.dashboard ?: return + val allowed = module == "dashboard" || dashboard.modules.contains(module) + if (allowed) { + uiState = uiState.copy(currentModule = module) + if (module == "lots" && uiState.lotsState.lots.isEmpty() && !uiState.lotsState.isLoading) { + refreshLots() + } + if (module == "sales_regular" && uiState.salesRegularState.items.isEmpty() && !uiState.salesRegularState.isLoading) { + refreshRegularSales() + } + if (module == "sales_jit" && uiState.salesJitState.items.isEmpty() && !uiState.salesJitState.isLoading) { + refreshJitSales() + } + if (module == "consignments" && uiState.consignmentsState.items.isEmpty() && !uiState.consignmentsState.isLoading) { + refreshConsignments() + } + if (module == "fund_requests" && uiState.fundRequestsState.items.isEmpty() && !uiState.fundRequestsState.isLoading) { + refreshFundRequests() + } + if (module == "purchases" && uiState.purchasesState.items.isEmpty() && !uiState.purchasesState.isLoading) { + refreshPurchases() + } + if (module == "purchase_analyses" && uiState.purchaseAnalysesState.items.isEmpty() && !uiState.purchaseAnalysesState.isLoading) { + refreshPurchaseAnalyses() + } + if (module == "purchase_realizations" && uiState.purchaseRealizationsState.items.isEmpty() && !uiState.purchaseRealizationsState.isLoading) { + refreshPurchaseRealizations() + } + if (module == "lot_transformations" && uiState.lotTransformationsState.items.isEmpty() && !uiState.lotTransformationsState.isLoading) { + refreshLotTransformations() + } + if (module == "stock_adjustments" && uiState.stockAdjustmentsState.items.isEmpty() && !uiState.stockAdjustmentsState.isLoading) { + refreshStockAdjustments() + } + if (module == "washing" && uiState.washingState.washings.isEmpty() && !uiState.washingState.isLoading) { + refreshWashing() + } + if (module == "receipts" && uiState.receiptsState.receipts.isEmpty() && !uiState.receiptsState.isLoading) { + refreshReceipts() + } + } + } + + fun refreshFundRequests() { + if (uiState.fundRequestsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.fundRequestsState + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val bootstrap = repository.loadFundRequestsBootstrap() + val items = repository.loadFundRequests() + val defaultAgent = uiState.fundRequestsState.selectedAgentId ?: bootstrap.agents.firstOrNull()?.id + val defaultAgentBank = bootstrap.agents + .find { it.id == defaultAgent } + ?.bankAccounts + ?.firstOrNull() + ?.id + val defaultCompanyBank = uiState.fundRequestsState.selectedCompanyBankAccountId + ?: bootstrap.companyBankAccounts.firstOrNull()?.id + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy( + isLoading = false, + items = items, + agents = bootstrap.agents, + companyBankAccounts = bootstrap.companyBankAccounts, + currencyCode = bootstrap.currencyCode, + selectedAgentId = defaultAgent, + selectedAgentBankAccountId = uiState.fundRequestsState.selectedAgentBankAccountId ?: defaultAgentBank, + selectedCompanyBankAccountId = defaultCompanyBank, + transferredAt = uiState.fundRequestsState.transferredAt.ifBlank { currentIsoDateTimeLocal() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + fundRequestsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshConsignments() { + if (uiState.consignmentsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.consignmentsState + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val bootstrap = repository.loadConsignmentsBootstrap() + val items = repository.loadConsignments() + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isLoading = false, + items = items, + salesOptions = bootstrap.sales, + buyerOptions = bootstrap.buyers, + lotOptions = bootstrap.lots, + closeDate = uiState.consignmentsState.closeDate.ifBlank { todayIsoDate() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + consignmentsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshJitSales() { + if (uiState.salesJitState.isLoading) return + viewModelScope.launch { + val previousState = uiState.salesJitState + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val items = repository.loadJitSales() + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isLoading = false, + items = items, + closeDate = uiState.salesJitState.closeDate.ifBlank { todayIsoDate() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesJitState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshRegularSales() { + if (uiState.salesRegularState.isLoading) return + viewModelScope.launch { + val previousState = uiState.salesRegularState + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val items = repository.loadRegularSales() + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isLoading = false, + items = items, + closeDate = uiState.salesRegularState.closeDate.ifBlank { todayIsoDate() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesRegularState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshLotTransformations() { + if (uiState.lotTransformationsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.lotTransformationsState + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val dashboard = uiState.dashboard + val lots = if (uiState.lotsState.lots.isNotEmpty()) uiState.lotsState.lots else repository.loadLots() + val items = repository.loadLotTransformations() + uiState = uiState.copy( + lotsState = uiState.lotsState.copy(lots = lots), + lotTransformationsState = uiState.lotTransformationsState.copy( + isLoading = false, + items = items, + selectableLots = lots.filter { it.availableQty > 0 && it.status == "ACTIVE" }, + transformationTypes = dashboard?.transformationTypes ?: defaultTransformationTypes(), + remainderModes = dashboard?.remainderModes ?: defaultRemainderModes(), + processingLossModes = dashboard?.processingLossModes ?: defaultProcessingLossModes(), + grades = dashboard?.grades.orEmpty(), + warehouses = dashboard?.warehouses.orEmpty(), + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotTransformationsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshPurchases() { + if (uiState.purchasesState.isLoading) return + viewModelScope.launch { + val previousState = uiState.purchasesState + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val dashboard = uiState.dashboard + val employees = repository.loadEmployees().filter { it.code != null } + val units = repository.loadUnits() + val items = repository.loadPurchases() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isLoading = false, + items = items, + employees = employees, + units = units, + grades = dashboard?.grades.orEmpty(), + warehouses = dashboard?.warehouses.orEmpty(), + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchasesState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshPurchaseAnalyses() { + if (uiState.purchaseAnalysesState.isLoading) return + viewModelScope.launch { + val previousState = uiState.purchaseAnalysesState + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val items = repository.loadPurchaseAnalyses() + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + isLoading = false, + items = items, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchaseAnalysesState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshPurchaseRealizations() { + if (uiState.purchaseRealizationsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.purchaseRealizationsState + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val items = repository.loadPurchaseRealizations() + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + isLoading = false, + items = items, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchaseRealizationsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshStockAdjustments() { + if (uiState.stockAdjustmentsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.stockAdjustmentsState + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val lots = if (uiState.lotsState.lots.isNotEmpty()) uiState.lotsState.lots else repository.loadLots() + val reasons = repository.loadAdjustmentReasons() + .filter { it.status == null || it.status.equals("ACTIVE", ignoreCase = true) } + val items = repository.loadStockAdjustments() + uiState = uiState.copy( + lotsState = uiState.lotsState.copy(lots = lots), + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + isLoading = false, + items = items, + reasons = reasons, + selectableLots = lots.filter { it.availableQty > 0 && it.status == "ACTIVE" }, + adjustmentDate = uiState.stockAdjustmentsState.adjustmentDate.ifBlank { todayIsoDate() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + stockAdjustmentsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshReceipts() { + if (uiState.receiptsState.isLoading) return + viewModelScope.launch { + val previousState = uiState.receiptsState + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val bootstrap = repository.loadReceiptsBootstrap() + val receipts = repository.loadReceipts() + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isLoading = false, + receipts = receipts, + purchases = bootstrap.purchases, + warehouses = bootstrap.warehouses, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + receiptsState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshWashing() { + if (uiState.washingState.isLoading) return + viewModelScope.launch { + val previousState = uiState.washingState + uiState = uiState.copy( + washingState = uiState.washingState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val bootstrap = repository.loadWashingBootstrap() + val washings = repository.loadWashings() + val lots = if (uiState.lotsState.lots.isNotEmpty()) uiState.lotsState.lots else repository.loadLots() + val inProgressLotIds = washings.filter { it.status == "IN_PROGRESS" }.map { it.lot.id }.toSet() + val editingLotId = previousState.selectedWashingId?.let { selectedId -> + washings.find { it.id == selectedId }?.lot?.id + } + val selectableLots = lots.filter { + it.status == "ACTIVE" && + it.availableQty > 0 && + (!inProgressLotIds.contains(it.id) || editingLotId == it.id) + } + + uiState = uiState.copy( + lotsState = uiState.lotsState.copy(lots = lots), + washingState = uiState.washingState.copy( + isLoading = false, + washings = washings, + washingPlaces = bootstrap.washingPlaces, + grades = bootstrap.grades, + warehouses = bootstrap.warehouses, + selectableLots = selectableLots, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + washingState = previousState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun refreshLots() { + if (uiState.lotsState.isLoading) return + viewModelScope.launch { + val previousLots = uiState.lotsState.lots + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isLoading = true, + inlineError = null, + ), + ) + try { + val lots = repository.loadLots() + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isLoading = false, + lots = lots, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isLoading = false, + lots = previousLots, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun updateLotQuery(value: String) { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy(query = value), + ) + } + + fun updateScanInput(value: String) { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy(scanInput = value), + ) + } + + fun openLotDetail(lotId: String) { + viewModelScope.launch { + val cached = uiState.lotsState.lotDetail?.takeIf { it.lot.id == lotId } + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + selectedLotId = lotId, + isLoadingDetail = cached == null, + lotDetail = cached, + inlineError = null, + ), + ) + if (cached != null) return@launch + + try { + val detail = repository.loadLotDetail(lotId) + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isLoadingDetail = false, + lotDetail = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isLoadingDetail = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closeLotDetail() { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + selectedLotId = null, + isLoadingDetail = false, + inlineError = null, + ), + ) + } + + fun scanLot() { + val code = uiState.lotsState.scanInput.trim() + performLotScan(code) + } + + fun scanLotCode(code: String) { + performLotScan(code.trim()) + } + + private fun performLotScan(code: String) { + if (code.isBlank()) { + uiState = uiState.copy(errorMessage = "Masukkan kode lot untuk pencarian.") + return + } + + viewModelScope.launch { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isScanning = true, + inlineError = null, + ), + ) + try { + val result = repository.scanLot(code) + uiState = uiState.copy( + currentModule = "lots", + lotsState = uiState.lotsState.copy( + isScanning = false, + scanInput = "", + selectedLotId = result.payload.lot.id, + lotDetail = result.payload.toLotDetailData(), + recentScans = (listOf(result) + uiState.lotsState.recentScans) + .distinctBy { it.payload.lot.id } + .take(10), + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotsState = uiState.lotsState.copy( + isScanning = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun openRecentScan(result: LotScanResult) { + uiState = uiState.copy( + currentModule = "lots", + lotsState = uiState.lotsState.copy( + selectedLotId = result.payload.lot.id, + lotDetail = result.payload.toLotDetailData(), + inlineError = null, + ), + ) + } + + fun openRegularSaleDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + selectedSaleId = id, + selectedSale = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadRegularSaleDetail(id) + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isLoading = false, + selectedSale = detail, + closeDate = detail.closeDate ?: todayIsoDate(), + closeLines = detail.lines.map { it.toCloseFormState() }, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closeRegularSaleDetail() { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + selectedSaleId = null, + selectedSale = null, + isClosing = false, + closeDate = todayIsoDate(), + closeLines = emptyList(), + inlineError = null, + ), + ) + } + + fun updateRegularSaleCloseDate(value: String) { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy(closeDate = value), + ) + } + + private fun updateRegularSaleCloseLine( + index: Int, + transform: (RegularSaleCloseLineFormState) -> RegularSaleCloseLineFormState, + ) { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + closeLines = uiState.salesRegularState.closeLines.mapIndexed { lineIndex, line -> + if (lineIndex == index) transform(line) else line + }, + ), + ) + } + + fun updateRegularSaleQtySold(index: Int, value: String) { + updateRegularSaleCloseLine(index) { it.copy(qtyActualSold = value) } + } + + fun updateRegularSaleQtyReturned(index: Int, value: String) { + updateRegularSaleCloseLine(index) { it.copy(qtyReturned = value) } + } + + fun updateRegularSalePriceActual(index: Int, value: String) { + updateRegularSaleCloseLine(index) { it.copy(sellingPriceActual = value) } + } + + fun closeRegularSale() { + val saleId = uiState.salesRegularState.selectedSaleId ?: return + val state = uiState.salesRegularState + if (state.closeDate.isBlank() || state.closeLines.isEmpty()) { + uiState = uiState.copy( + salesRegularState = state.copy(inlineError = "Tanggal close dan seluruh line penjualan wajib diisi."), + ) + return + } + + val payloadLines = state.closeLines.mapNotNull { line -> + val qtyActualSold = line.qtyActualSold.toDoubleOrNull() + val qtyReturned = line.qtyReturned.toDoubleOrNull() + val sellingPriceActual = line.sellingPriceActual.toDoubleOrNull() + if (qtyActualSold == null || qtyReturned == null || sellingPriceActual == null) { + null + } else { + RegularSaleCloseLinePayload( + lineId = line.lineId, + qtyActualSold = qtyActualSold, + qtyReturned = qtyReturned, + sellingPriceActual = sellingPriceActual, + ) + } + } + + if (payloadLines.size != state.closeLines.size) { + uiState = uiState.copy( + salesRegularState = state.copy(inlineError = "Semua input close penjualan harus numerik dan lengkap."), + ) + return + } + + if (payloadLines.any { it.qtyActualSold + it.qtyReturned > state.closeLines.first { form -> form.lineId == it.lineId }.qtyPlanned }) { + uiState = uiState.copy( + salesRegularState = state.copy(inlineError = "Qty sold + returned tidak boleh melebihi qty planned."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy( + salesRegularState = state.copy( + isClosing = true, + inlineError = null, + ), + ) + try { + val detail = repository.closeRegularSale( + saleId, + RegularSaleClosePayload( + closeDate = state.closeDate, + lines = payloadLines, + ), + ) + refreshRegularSales() + refreshLots() + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isClosing = false, + selectedSale = detail, + closeDate = detail.closeDate ?: state.closeDate, + closeLines = detail.lines.map { it.toCloseFormState() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesRegularState = uiState.salesRegularState.copy( + isClosing = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun openJitSaleDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + selectedSaleId = id, + selectedSale = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadJitSaleDetail(id) + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isLoading = false, + selectedSale = detail, + closeDate = detail.closeDate ?: todayIsoDate(), + closeLines = detail.lines.map { it.toCloseFormState() }, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closeJitSaleDetail() { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + selectedSaleId = null, + selectedSale = null, + isClosing = false, + closeDate = todayIsoDate(), + closeLines = emptyList(), + inlineError = null, + ), + ) + } + + fun updateJitSaleCloseDate(value: String) { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy(closeDate = value), + ) + } + + fun updateJitSalePriceActual(index: Int, value: String) { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + closeLines = uiState.salesJitState.closeLines.mapIndexed { lineIndex, line -> + if (lineIndex == index) line.copy(sellingPriceActual = value) else line + }, + ), + ) + } + + fun closeJitSale() { + val saleId = uiState.salesJitState.selectedSaleId ?: return + val state = uiState.salesJitState + if (state.closeDate.isBlank() || state.closeLines.isEmpty()) { + uiState = uiState.copy( + salesJitState = state.copy(inlineError = "Tanggal close dan seluruh line penjualan wajib diisi."), + ) + return + } + + val payloadLines = state.closeLines.mapNotNull { line -> + val sellingPriceActual = line.sellingPriceActual.toDoubleOrNull() + if (sellingPriceActual == null) null else JitSaleCloseLinePayload( + lineId = line.lineId, + sellingPriceActual = sellingPriceActual, + ) + } + + if (payloadLines.size != state.closeLines.size) { + uiState = uiState.copy( + salesJitState = state.copy(inlineError = "Harga jual aktual semua line harus numerik dan lengkap."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy( + salesJitState = state.copy( + isClosing = true, + inlineError = null, + ), + ) + try { + val detail = repository.closeJitSale( + saleId, + JitSaleClosePayload( + closeDate = state.closeDate, + lines = payloadLines, + ), + ) + refreshJitSales() + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isClosing = false, + selectedSale = detail, + closeDate = detail.closeDate ?: state.closeDate, + closeLines = detail.lines.map { it.toCloseFormState() }, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + salesJitState = uiState.salesJitState.copy( + isClosing = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun openConsignmentDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + selectedConsignmentId = id, + selectedConsignment = null, + selectedLineId = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadConsignmentDetail(id) + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isLoading = false, + selectedConsignment = detail, + closeDate = todayIsoDate(), + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closeConsignmentDetail() { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + selectedConsignmentId = null, + selectedConsignment = null, + selectedLineId = null, + isClosing = false, + inlineError = null, + ), + ) + } + + fun selectConsignmentLine(lineId: String?) { + val line = uiState.consignmentsState.selectedConsignment?.lines?.find { it.id == lineId } + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + selectedLineId = lineId, + closeDate = line?.closeDate ?: todayIsoDate(), + sellingPrice = line?.sellingPrice?.let(::formatQuantityInput) ?: "", + qtySold = line?.qtySold?.let(::formatQuantityInput) ?: "", + qtyReturned = line?.qtyReturned?.let(::formatQuantityInput) ?: "", + salesCommission = line?.salesCommission?.let(::formatQuantityInput) ?: "", + inlineError = null, + ), + ) + } + + fun updateConsignmentCloseDate(value: String) { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy(closeDate = value), + ) + } + + fun updateConsignmentSellingPrice(value: String) { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy(sellingPrice = value), + ) + } + + fun updateConsignmentQtySold(value: String) { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy(qtySold = value), + ) + } + + fun updateConsignmentQtyReturned(value: String) { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy(qtyReturned = value), + ) + } + + fun updateConsignmentSalesCommission(value: String) { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy(salesCommission = value), + ) + } + + fun closeConsignmentLine() { + val state = uiState.consignmentsState + val lineId = state.selectedLineId ?: return + val selectedLine = state.selectedConsignment?.lines?.find { it.id == lineId } ?: return + val sellingPrice = state.sellingPrice.toDoubleOrNull() + val qtySold = state.qtySold.toDoubleOrNull() + val qtyReturned = state.qtyReturned.toDoubleOrNull() + val salesCommission = state.salesCommission.toDoubleOrNull() + + if (state.closeDate.isBlank() || sellingPrice == null || qtySold == null || qtyReturned == null || salesCommission == null) { + uiState = uiState.copy( + consignmentsState = state.copy(inlineError = "Tanggal close, harga jual, qty sold, qty returned, dan komisi sales wajib diisi."), + ) + return + } + + if (qtySold == 0.0 && qtyReturned == 0.0) { + uiState = uiState.copy( + consignmentsState = state.copy(inlineError = "Isi qty sold atau qty returned untuk menutup item."), + ) + return + } + + if (qtySold + qtyReturned > selectedLine.qtyConsigned) { + uiState = uiState.copy( + consignmentsState = state.copy(inlineError = "Qty sold + returned tidak boleh melebihi qty consigned."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy( + consignmentsState = state.copy( + isClosing = true, + inlineError = null, + ), + ) + try { + repository.closeConsignmentLine( + lineId = lineId, + payload = ConsignmentCloseLinePayload( + closeDate = state.closeDate, + sellingPrice = sellingPrice, + qtySold = qtySold, + qtyReturned = qtyReturned, + salesCommission = salesCommission, + ), + ) + val consignmentId = state.selectedConsignmentId ?: return@launch + val detail = repository.loadConsignmentDetail(consignmentId) + refreshConsignments() + refreshLots() + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isClosing = false, + selectedConsignment = detail, + selectedLineId = null, + sellingPrice = "", + qtySold = "", + qtyReturned = "", + salesCommission = "", + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + consignmentsState = uiState.consignmentsState.copy( + isClosing = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun updateFundRequestTransferType(value: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(transferType = value, inlineError = null), + ) + } + + fun updateFundRequestReferenceNo(value: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(referenceNo = value, inlineError = null), + ) + } + + fun selectFundRequestAgent(agentId: String) { + val agent = uiState.fundRequestsState.agents.find { it.id == agentId } + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy( + selectedAgentId = agentId, + selectedAgentBankAccountId = agent?.bankAccounts?.firstOrNull()?.id, + inlineError = null, + ), + ) + } + + fun selectFundRequestAgentBank(accountId: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(selectedAgentBankAccountId = accountId, inlineError = null), + ) + } + + fun selectFundRequestCompanyBank(accountId: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(selectedCompanyBankAccountId = accountId, inlineError = null), + ) + } + + fun updateFundRequestAmount(value: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(amount = value, inlineError = null), + ) + } + + fun updateFundRequestTransferredAt(value: String) { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy(transferredAt = value, inlineError = null), + ) + } + + fun saveFundRequest() { + val state = uiState.fundRequestsState + if ( + state.referenceNo.isBlank() || + state.selectedAgentId.isNullOrBlank() || + state.selectedAgentBankAccountId.isNullOrBlank() || + state.selectedCompanyBankAccountId.isNullOrBlank() || + state.amount.toDoubleOrNull() == null || + state.amount.toDouble() <= 0 || + state.transferredAt.isBlank() + ) { + uiState = uiState.copy( + fundRequestsState = state.copy(inlineError = "Tipe transfer, referensi, agen, rekening, nominal, dan waktu transfer wajib diisi."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy( + fundRequestsState = state.copy( + isSaving = true, + inlineError = null, + ), + ) + try { + repository.createFundRequest( + transferType = state.transferType, + referenceNo = state.referenceNo, + agentId = state.selectedAgentId, + agentBankAccountId = state.selectedAgentBankAccountId, + companyBankAccountId = state.selectedCompanyBankAccountId, + amount = state.amount, + transferredAt = state.transferredAt, + ) + refreshFundRequests() + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy( + isSaving = false, + referenceNo = "", + amount = "", + transferredAt = currentIsoDateTimeLocal(), + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + fundRequestsState = uiState.fundRequestsState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun openPurchaseDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + screen = PurchasesScreen.Detail, + selectedPurchaseId = id, + selectedPurchase = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadPurchaseDetail(id) + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isLoading = false, + selectedPurchase = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closePurchaseDetail() { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + screen = PurchasesScreen.List, + selectedPurchaseId = null, + selectedPurchase = null, + isActing = false, + isSaving = false, + inlineError = null, + ), + ) + } + + fun openCreatePurchase() { + if (uiState.purchasesState.employees.isEmpty() || uiState.purchasesState.units.isEmpty()) { + refreshPurchases() + } + val defaultWarehouse = uiState.purchasesState.warehouses.firstOrNull() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + screen = PurchasesScreen.Create, + selectedPurchaseId = null, + selectedPurchase = null, + purchaseDate = todayIsoDate(), + receivedAt = currentIsoDateTimeLocal(), + selectedEmployeeId = uiState.purchasesState.employees.firstOrNull()?.id, + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + notes = "", + lines = listOf( + PurchaseLineFormState( + selectedUnitId = uiState.purchasesState.units.firstOrNull()?.id, + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + ), + ), + inlineError = null, + ), + ) + } + + fun openEditPurchase() { + val detail = uiState.purchasesState.selectedPurchase ?: return + val defaultWarehouse = uiState.purchasesState.warehouses.firstOrNull() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + screen = PurchasesScreen.Edit, + purchaseDate = detail.purchaseDate, + receivedAt = detail.receivedAt ?: currentIsoDateTimeLocal(), + selectedEmployeeId = detail.receivedByEmployee?.id ?: uiState.purchasesState.employees.firstOrNull()?.id, + selectedWarehouseId = detail.lines.firstOrNull()?.warehouse?.id ?: defaultWarehouse?.id, + selectedWarehouseLocationId = detail.lines.firstOrNull()?.warehouseLocation?.id ?: defaultWarehouse?.locations?.firstOrNull()?.id, + notes = detail.notes.orEmpty(), + lines = detail.lines.map { line -> + PurchaseLineFormState( + selectedGradeId = line.grade?.id, + qtyOrdered = formatQuantityInput(line.qtyOrdered), + selectedUnitId = line.unit.id, + unitPrice = formatQuantityInput(line.unitPrice), + unitCost = formatQuantityInput(line.unitCost), + selectedWarehouseId = line.warehouse?.id ?: defaultWarehouse?.id, + selectedWarehouseLocationId = line.warehouseLocation?.id ?: defaultWarehouse?.locations?.firstOrNull()?.id, + notes = line.notes.orEmpty(), + ) + }.ifEmpty { + listOf( + PurchaseLineFormState( + selectedUnitId = uiState.purchasesState.units.firstOrNull()?.id, + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + ), + ) + }, + inlineError = null, + ), + ) + } + + fun closePurchaseEditor() { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + screen = if (uiState.purchasesState.selectedPurchase != null) PurchasesScreen.Detail else PurchasesScreen.List, + isSaving = false, + inlineError = null, + ), + ) + } + + fun updatePurchaseDate(value: String) { + uiState = uiState.copy(purchasesState = uiState.purchasesState.copy(purchaseDate = value, inlineError = null)) + } + + fun updatePurchaseReceivedAt(value: String) { + uiState = uiState.copy(purchasesState = uiState.purchasesState.copy(receivedAt = value, inlineError = null)) + } + + fun selectPurchaseEmployee(id: String) { + uiState = uiState.copy(purchasesState = uiState.purchasesState.copy(selectedEmployeeId = id, inlineError = null)) + } + + fun selectPurchaseWarehouse(id: String) { + val warehouse = uiState.purchasesState.warehouses.find { it.id == id } + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + selectedWarehouseId = id, + selectedWarehouseLocationId = warehouse?.locations?.firstOrNull()?.id, + lines = uiState.purchasesState.lines.map { line -> + line.copy( + selectedWarehouseId = id, + selectedWarehouseLocationId = warehouse?.locations?.firstOrNull()?.id, + ) + }, + inlineError = null, + ), + ) + } + + fun selectPurchaseWarehouseLocation(id: String?) { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + selectedWarehouseLocationId = id, + lines = uiState.purchasesState.lines.map { it.copy(selectedWarehouseLocationId = id) }, + inlineError = null, + ), + ) + } + + fun updatePurchaseNotes(value: String) { + uiState = uiState.copy(purchasesState = uiState.purchasesState.copy(notes = value, inlineError = null)) + } + + fun addPurchaseLine() { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + lines = uiState.purchasesState.lines + PurchaseLineFormState( + selectedUnitId = uiState.purchasesState.units.firstOrNull()?.id, + selectedWarehouseId = uiState.purchasesState.selectedWarehouseId, + selectedWarehouseLocationId = uiState.purchasesState.selectedWarehouseLocationId, + ), + inlineError = null, + ), + ) + } + + fun removePurchaseLine(index: Int) { + if (uiState.purchasesState.lines.size <= 1) return + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + lines = uiState.purchasesState.lines.filterIndexed { currentIndex, _ -> currentIndex != index }, + inlineError = null, + ), + ) + } + + fun selectPurchaseLineGrade(index: Int, id: String?) { + updatePurchaseLine(index) { it.copy(selectedGradeId = id) } + } + + fun updatePurchaseLineQty(index: Int, value: String) { + updatePurchaseLine(index) { it.copy(qtyOrdered = value) } + } + + fun selectPurchaseLineUnit(index: Int, id: String) { + updatePurchaseLine(index) { it.copy(selectedUnitId = id) } + } + + fun updatePurchaseLineUnitPrice(index: Int, value: String) { + updatePurchaseLine(index) { it.copy(unitPrice = value, unitCost = if (it.unitCost.isBlank()) value else it.unitCost) } + } + + fun updatePurchaseLineUnitCost(index: Int, value: String) { + updatePurchaseLine(index) { it.copy(unitCost = value) } + } + + fun updatePurchaseLineNotes(index: Int, value: String) { + updatePurchaseLine(index) { it.copy(notes = value) } + } + + fun savePurchase() { + val state = uiState.purchasesState + if (state.purchaseDate.isBlank() || state.receivedAt.isBlank() || state.selectedEmployeeId.isNullOrBlank() || state.lines.isEmpty()) { + uiState = uiState.copy( + purchasesState = state.copy(inlineError = "Tanggal beli, waktu diterima, penerima, dan minimal satu line wajib diisi."), + ) + return + } + + val lines = state.lines.mapNotNull { line -> + val qty = line.qtyOrdered.toDoubleOrNull() + val unitPrice = line.unitPrice.toDoubleOrNull() + val unitCost = line.unitCost.toDoubleOrNull() + val unitId = line.selectedUnitId + if (qty == null || qty <= 0 || unitPrice == null || unitPrice < 0 || unitCost == null || unitCost < 0 || unitId.isNullOrBlank()) { + null + } else { + PurchaseCreateLinePayload( + gradeId = line.selectedGradeId, + qtyOrdered = qty, + qtyReceived = qty, + qtyAccepted = qty, + qtyRejected = 0.0, + unitId = unitId, + unitPrice = unitPrice, + unitCost = unitCost, + classificationStatus = "FINAL", + warehouseId = line.selectedWarehouseId ?: state.selectedWarehouseId, + warehouseLocationId = line.selectedWarehouseLocationId ?: state.selectedWarehouseLocationId, + notes = line.notes.ifBlank { null }, + ) + } + } + + if (lines.size != state.lines.size) { + uiState = uiState.copy( + purchasesState = state.copy(inlineError = "Semua line pembelian wajib lengkap dan numerik."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy(purchasesState = state.copy(isSaving = true, inlineError = null)) + try { + val payload = PurchaseCreatePayload( + receivedByEmployeeId = state.selectedEmployeeId, + receivedAt = state.receivedAt, + purchaseDate = state.purchaseDate, + warehouseId = state.selectedWarehouseId, + warehouseLocationId = state.selectedWarehouseLocationId, + notes = state.notes.ifBlank { null }, + lines = lines, + ) + val detail = if (state.screen == PurchasesScreen.Edit && !state.selectedPurchaseId.isNullOrBlank()) { + repository.updatePurchase(state.selectedPurchaseId, payload) + } else { + repository.createPurchase(payload) + } + refreshPurchases() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isSaving = false, + screen = PurchasesScreen.Detail, + selectedPurchaseId = detail.id, + selectedPurchase = detail, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun submitPurchase() { + val purchaseId = uiState.purchasesState.selectedPurchaseId ?: return + viewModelScope.launch { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = true, + inlineError = null, + ), + ) + try { + val detail = repository.submitPurchase(purchaseId) + refreshPurchases() + refreshLots() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = false, + selectedPurchase = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun cancelPurchase() { + val purchaseId = uiState.purchasesState.selectedPurchaseId ?: return + viewModelScope.launch { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = true, + inlineError = null, + ), + ) + try { + repository.cancelPurchase(purchaseId) + val detail = repository.loadPurchaseDetail(purchaseId) + refreshPurchases() + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = false, + selectedPurchase = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + isActing = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun openPurchaseAnalysisDetail(purchaseId: String) { + viewModelScope.launch { + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + selectedPurchaseId = purchaseId, + selectedDetail = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadPurchaseAnalysisDetail(purchaseId) + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + isLoading = false, + selectedDetail = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun closePurchaseAnalysisDetail() { + uiState = uiState.copy( + purchaseAnalysesState = uiState.purchaseAnalysesState.copy( + selectedPurchaseId = null, + selectedDetail = null, + inlineError = null, + ), + ) + } + + fun openPurchaseRealizationDetail(purchaseId: String) { + viewModelScope.launch { + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + selectedPurchaseId = purchaseId, + selectedDetail = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadPurchaseRealizationDetail(purchaseId) + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + isLoading = false, + selectedDetail = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun closePurchaseRealizationDetail() { + uiState = uiState.copy( + purchaseRealizationsState = uiState.purchaseRealizationsState.copy( + selectedPurchaseId = null, + selectedDetail = null, + inlineError = null, + ), + ) + } + + fun openLotTransformationDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + screen = LotTransformationsScreen.Detail, + selectedTransformationId = id, + selectedTransformation = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadLotTransformationDetail(id) + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + isLoading = false, + selectedTransformation = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun closeLotTransformationDetail() { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + screen = LotTransformationsScreen.List, + selectedTransformationId = null, + selectedTransformation = null, + inlineError = null, + ), + ) + } + + fun openCreateLotTransformation() { + if (uiState.lotTransformationsState.selectableLots.isEmpty()) { + refreshLotTransformations() + } + val defaultWarehouse = uiState.lotTransformationsState.warehouses.firstOrNull() + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + screen = LotTransformationsScreen.Create, + selectedTransformationId = null, + selectedTransformation = null, + transformationType = "MIX", + transformationDate = todayIsoDate(), + remainderMode = uiState.lotTransformationsState.remainderModes.firstOrNull()?.code, + processingLossMode = uiState.lotTransformationsState.processingLossModes.firstOrNull()?.code, + notes = "", + inputs = listOf( + LotTransformationInputFormState(), + LotTransformationInputFormState(), + ), + outputs = listOf( + LotTransformationOutputFormState( + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + ), + ), + inlineError = null, + ), + ) + } + + fun closeLotTransformationScreen() { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + screen = LotTransformationsScreen.List, + selectedTransformationId = null, + selectedTransformation = null, + isSaving = false, + inlineError = null, + ), + ) + } + + fun updateLotTransformationType(value: String) { + val inputs = if (value == "REGRADE") { + listOf(uiState.lotTransformationsState.inputs.firstOrNull() ?: LotTransformationInputFormState()) + } else { + uiState.lotTransformationsState.inputs.ifEmpty { + listOf(LotTransformationInputFormState(), LotTransformationInputFormState()) + }.let { existing -> + if (existing.size >= 2) existing else existing + LotTransformationInputFormState() + } + } + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + transformationType = value, + inputs = inputs, + inlineError = null, + ), + ) + } + + fun updateLotTransformationDate(value: String) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + transformationDate = value, + inlineError = null, + ), + ) + } + + fun updateLotTransformationRemainderMode(value: String?) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + remainderMode = value, + inlineError = null, + ), + ) + } + + fun updateLotTransformationProcessingLossMode(value: String?) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + processingLossMode = value, + inlineError = null, + ), + ) + } + + fun updateLotTransformationNotes(value: String) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + notes = value, + inlineError = null, + ), + ) + } + + fun addLotTransformationInput() { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + inputs = uiState.lotTransformationsState.inputs + LotTransformationInputFormState(), + inlineError = null, + ), + ) + } + + fun removeLotTransformationInput(index: Int) { + val current = uiState.lotTransformationsState.inputs + if (current.size <= 1 || index !in current.indices) return + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + inputs = current.filterIndexed { currentIndex, _ -> currentIndex != index }, + inlineError = null, + ), + ) + } + + fun updateLotTransformationInputQuery(index: Int, value: String) { + updateLotTransformationInput(index) { + it.copy( + selectedLotId = null, + lotQuery = value, + ) + } + } + + fun selectLotTransformationInputLot(index: Int, lotId: String) { + val lot = uiState.lotTransformationsState.selectableLots.find { it.id == lotId } ?: return + updateLotTransformationInput(index) { + it.copy( + selectedLotId = lot.id, + lotQuery = lot.lotCode, + ) + } + } + + fun updateLotTransformationInputQty(index: Int, value: String) { + updateLotTransformationInput(index) { it.copy(qtyUsed = value) } + } + + fun updateLotTransformationInputNotes(index: Int, value: String) { + updateLotTransformationInput(index) { it.copy(notes = value) } + } + + fun clearLotTransformationInputLot(index: Int) { + updateLotTransformationInput(index) { + it.copy(selectedLotId = null, lotQuery = "") + } + } + + fun addLotTransformationOutput() { + val defaultWarehouse = uiState.lotTransformationsState.warehouses.firstOrNull() + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + outputs = uiState.lotTransformationsState.outputs + LotTransformationOutputFormState( + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + ), + inlineError = null, + ), + ) + } + + fun removeLotTransformationOutput(index: Int) { + val current = uiState.lotTransformationsState.outputs + if (current.size <= 1 || index !in current.indices) return + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + outputs = current.filterIndexed { currentIndex, _ -> currentIndex != index }, + inlineError = null, + ), + ) + } + + fun selectLotTransformationOutputGrade(index: Int, gradeId: String) { + updateLotTransformationOutput(index) { it.copy(selectedGradeId = gradeId) } + } + + fun selectLotTransformationOutputWarehouse(index: Int, warehouseId: String) { + val warehouse = uiState.lotTransformationsState.warehouses.find { it.id == warehouseId } + updateLotTransformationOutput(index) { + it.copy( + selectedWarehouseId = warehouseId, + selectedWarehouseLocationId = warehouse?.locations?.firstOrNull()?.id, + ) + } + } + + fun selectLotTransformationOutputLocation(index: Int, locationId: String?) { + updateLotTransformationOutput(index) { it.copy(selectedWarehouseLocationId = locationId) } + } + + fun updateLotTransformationOutputQty(index: Int, value: String) { + updateLotTransformationOutput(index) { it.copy(qtyProduced = value) } + } + + fun updateLotTransformationOutputNotes(index: Int, value: String) { + updateLotTransformationOutput(index) { it.copy(notes = value) } + } + + fun saveLotTransformation() { + val state = uiState.lotTransformationsState + if (state.transformationDate.isBlank()) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Tanggal transformasi wajib diisi."), + ) + return + } + + val inputs = state.inputs.mapNotNull { input -> + val lot = state.selectableLots.find { it.id == input.selectedLotId } + val qtyUsed = input.qtyUsed.toDoubleOrNull() + if (lot == null || qtyUsed == null || qtyUsed <= 0) { + null + } else { + LotTransformationCreateInputPayload( + sourceLotCode = lot.lotCode, + qtyUsed = qtyUsed, + notes = input.notes.ifBlank { null }, + ) + } + } + + val outputs = state.outputs.mapNotNull { output -> + val qtyProduced = output.qtyProduced.toDoubleOrNull() + val warehouseId = output.selectedWarehouseId + val gradeId = output.selectedGradeId + if (qtyProduced == null || qtyProduced <= 0 || warehouseId.isNullOrBlank() || gradeId.isNullOrBlank()) { + null + } else { + LotTransformationCreateOutputPayload( + gradeId = gradeId, + warehouseId = warehouseId, + warehouseLocationId = output.selectedWarehouseLocationId, + qtyProduced = qtyProduced, + notes = output.notes.ifBlank { null }, + ) + } + } + + if (inputs.size != state.inputs.size || outputs.size != state.outputs.size) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Semua input dan output transformasi harus lengkap serta numerik."), + ) + return + } + + if (state.transformationType == "REGRADE" && inputs.size != 1) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Regrade harus memakai tepat satu lot sumber."), + ) + return + } + + if (state.transformationType == "MIX" && inputs.size < 2) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Mix minimal memakai dua lot sumber."), + ) + return + } + + if (inputs.map { it.sourceLotCode.uppercase() }.distinct().size != inputs.size) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Lot sumber tidak boleh duplikat."), + ) + return + } + + val totalInput = inputs.sumOf { it.qtyUsed } + val totalOutput = outputs.sumOf { it.qtyProduced } + if (totalOutput > totalInput) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Total output tidak boleh melebihi total input."), + ) + return + } + + if (state.transformationType == "REGRADE") { + val selectedLot = state.selectableLots.find { it.id == state.inputs.firstOrNull()?.selectedLotId } + val qtyUsed = inputs.firstOrNull()?.qtyUsed ?: 0.0 + val remainderQty = ((selectedLot?.availableQty ?: 0.0) - qtyUsed) + if (remainderQty > 0 && state.remainderMode.isNullOrBlank()) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Pilih penanganan sisa lot sumber untuk regrade."), + ) + return + } + if (totalInput > totalOutput && state.processingLossMode.isNullOrBlank()) { + uiState = uiState.copy( + lotTransformationsState = state.copy(inlineError = "Pilih penanganan selisih hasil regrade."), + ) + return + } + } + + viewModelScope.launch { + uiState = uiState.copy( + lotTransformationsState = state.copy( + isSaving = true, + inlineError = null, + ), + ) + try { + val detail = repository.createLotTransformation( + LotTransformationCreatePayload( + transformationType = state.transformationType, + transformationDate = state.transformationDate, + remainderMode = state.remainderMode, + processingLossMode = state.processingLossMode, + notes = state.notes.ifBlank { null }, + inputs = inputs, + outputs = outputs, + ), + ) + refreshLotTransformations() + refreshLots() + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + isSaving = false, + screen = LotTransformationsScreen.Detail, + selectedTransformationId = detail.id, + selectedTransformation = detail, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun selectStockAdjustmentLot(id: String) { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + selectedLotId = id, + inlineError = null, + ), + ) + } + + fun selectStockAdjustmentReason(id: String) { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + selectedReasonId = id, + inlineError = null, + ), + ) + } + + fun updateStockAdjustmentDate(value: String) { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy(adjustmentDate = value), + ) + } + + fun updateStockAdjustmentQty(value: String) { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy(qtyChange = value), + ) + } + + fun updateStockAdjustmentNotes(value: String) { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy(notes = value), + ) + } + + fun saveStockAdjustment() { + val state = uiState.stockAdjustmentsState + val lotId = state.selectedLotId + val reasonId = state.selectedReasonId + val qtyChange = state.qtyChange.toDoubleOrNull() + if (lotId.isNullOrBlank() || reasonId.isNullOrBlank() || state.adjustmentDate.isBlank() || qtyChange == null || qtyChange == 0.0) { + uiState = uiState.copy( + stockAdjustmentsState = state.copy(inlineError = "Lot, alasan, tanggal, dan qty adjustment wajib diisi."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy( + stockAdjustmentsState = state.copy( + isSaving = true, + inlineError = null, + ), + ) + try { + repository.createStockAdjustment( + StockAdjustmentCreatePayload( + lotId = lotId, + adjustmentReasonId = reasonId, + adjustmentDate = state.adjustmentDate, + qtyChange = qtyChange, + notes = state.notes.ifBlank { null }, + ), + ) + refreshStockAdjustments() + refreshLots() + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + isSaving = false, + selectedLotId = null, + selectedReasonId = null, + adjustmentDate = todayIsoDate(), + qtyChange = "", + notes = "", + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + stockAdjustmentsState = uiState.stockAdjustmentsState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun openCreateWashing() { + if (uiState.washingState.selectableLots.isEmpty()) { + refreshWashing() + } + uiState = uiState.copy( + washingState = uiState.washingState.copy( + screen = WashingScreen.Create, + selectedWashingId = null, + selectedLotId = null, + selectedWashingPlaceId = uiState.washingState.washingPlaces.firstOrNull()?.id, + washingCost = "", + durationHours = "24", + inlineError = null, + ), + ) + } + + fun openEditWashing(id: String) { + val item = uiState.washingState.washings.find { it.id == id } ?: return + uiState = uiState.copy( + washingState = uiState.washingState.copy( + screen = WashingScreen.Edit, + selectedWashingId = id, + selectedLotId = item.lot.id, + selectedWashingPlaceId = item.washingPlace.id, + washingCost = item.washingCost.toInt().toString(), + durationHours = item.durationHours.toString(), + inlineError = null, + ), + ) + } + + fun openCompleteWashing(id: String) { + val item = uiState.washingState.washings.find { it.id == id } ?: return + val defaultWarehouse = uiState.washingState.warehouses.firstOrNull() + uiState = uiState.copy( + washingState = uiState.washingState.copy( + screen = WashingScreen.Complete, + selectedWashingId = id, + afterQty = formatQuantityInput(item.afterQty ?: item.beforeQty), + selectedGradeId = null, + selectedWarehouseId = defaultWarehouse?.id, + selectedWarehouseLocationId = defaultWarehouse?.locations?.firstOrNull()?.id, + inlineError = null, + ), + ) + } + + fun closeWashingScreen() { + uiState = uiState.copy( + washingState = uiState.washingState.copy( + screen = WashingScreen.List, + selectedWashingId = null, + inlineError = null, + isSaving = false, + isCompleting = false, + ), + ) + } + + fun openCreateReceipt() { + if (uiState.receiptsState.purchases.isEmpty()) { + refreshReceipts() + } + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + screen = ReceiptsScreen.Create, + selectedReceiptId = null, + selectedReceipt = null, + selectedPurchaseId = null, + receiptDate = todayIsoDate(), + notes = "", + lines = emptyList(), + inlineError = null, + ), + ) + } + + fun closeReceiptScreen() { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + screen = ReceiptsScreen.List, + selectedReceiptId = null, + selectedReceipt = null, + isSaving = false, + isGeneratingLots = false, + inlineError = null, + ), + ) + } + + fun selectReceiptPurchase(id: String) { + val purchase = uiState.receiptsState.purchases.find { it.id == id } + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + selectedPurchaseId = id, + lines = purchase?.lines?.map { it.toFormState() } ?: emptyList(), + inlineError = null, + ), + ) + } + + fun updateReceiptDate(value: String) { + uiState = uiState.copy(receiptsState = uiState.receiptsState.copy(receiptDate = value)) + } + + fun updateReceiptNotes(value: String) { + uiState = uiState.copy(receiptsState = uiState.receiptsState.copy(notes = value)) + } + + fun updateReceiptLine(index: Int, transform: (ReceiptLineFormState) -> ReceiptLineFormState) { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + lines = uiState.receiptsState.lines.mapIndexed { lineIndex, line -> + if (lineIndex == index) transform(line) else line + }, + ), + ) + } + + fun updateReceiptLineQtyReceived(index: Int, value: String) { + updateReceiptLine(index) { line -> + line.copy( + qtyReceived = value, + qtyAccepted = value, + qtyRejected = if (value.isBlank()) line.qtyRejected else "0", + ) + } + } + + fun updateReceiptLineQtyAccepted(index: Int, value: String) { + updateReceiptLine(index) { it.copy(qtyAccepted = value) } + } + + fun updateReceiptLineQtyRejected(index: Int, value: String) { + updateReceiptLine(index) { it.copy(qtyRejected = value) } + } + + fun updateReceiptLineUnitCost(index: Int, value: String) { + updateReceiptLine(index) { it.copy(unitCost = value) } + } + + fun updateReceiptLineWarehouse(index: Int, warehouseId: String) { + val warehouse = uiState.receiptsState.warehouses.find { it.id == warehouseId } + updateReceiptLine(index) { + it.copy( + warehouseId = warehouseId, + warehouseLocationId = warehouse?.locations?.firstOrNull()?.id.orEmpty(), + ) + } + } + + fun updateReceiptLineLocation(index: Int, locationId: String) { + updateReceiptLine(index) { it.copy(warehouseLocationId = locationId) } + } + + fun updateReceiptLineNotes(index: Int, value: String) { + updateReceiptLine(index) { it.copy(notes = value) } + } + + fun openReceiptDetail(id: String) { + viewModelScope.launch { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + screen = ReceiptsScreen.Detail, + selectedReceiptId = id, + selectedReceipt = null, + isLoading = true, + inlineError = null, + ), + ) + try { + val detail = repository.loadReceiptDetail(id) + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isLoading = false, + selectedReceipt = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isLoading = false, + inlineError = error.toReadableMessage(), + ), + errorMessage = error.toReadableMessage(), + ) + } + } + } + } + + fun saveReceipt() { + val state = uiState.receiptsState + if (state.selectedPurchaseId.isNullOrBlank() || state.receiptDate.isBlank() || state.lines.isEmpty()) { + uiState = uiState.copy( + receiptsState = state.copy(inlineError = "Purchase, tanggal receipt, dan minimal satu line wajib diisi."), + ) + return + } + + val lines = state.lines.mapNotNull { line -> + val qtyReceived = line.qtyReceived.toDoubleOrNull() + val qtyAccepted = line.qtyAccepted.toDoubleOrNull() + val qtyRejected = line.qtyRejected.toDoubleOrNull() + val unitCost = line.unitCost.toDoubleOrNull() + if ( + qtyReceived == null || + qtyAccepted == null || + qtyRejected == null || + unitCost == null || + line.warehouseId.isBlank() + ) { + null + } else { + ReceiptCreateLinePayload( + purchaseLineId = line.purchaseLineId, + gradeId = line.gradeId, + qtyReceived = qtyReceived, + qtyAccepted = qtyAccepted, + qtyRejected = qtyRejected, + unitId = line.unitId, + unitCost = unitCost, + warehouseId = line.warehouseId, + warehouseLocationId = line.warehouseLocationId.ifBlank { null }, + notes = line.notes.ifBlank { null }, + ) + } + } + + if (lines.size != state.lines.size) { + uiState = uiState.copy( + receiptsState = state.copy(inlineError = "Semua line receipt harus lengkap dan numerik."), + ) + return + } + + if (lines.any { it.qtyAccepted + it.qtyRejected > it.qtyReceived }) { + uiState = uiState.copy( + receiptsState = state.copy(inlineError = "Qty accepted + rejected tidak boleh melebihi qty received."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy(receiptsState = state.copy(isSaving = true, inlineError = null)) + try { + val detail = repository.createReceipt( + ReceiptCreatePayload( + purchaseId = state.selectedPurchaseId, + receiptDate = state.receiptDate, + notes = state.notes.ifBlank { null }, + lines = lines, + ), + ) + refreshReceipts() + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isSaving = false, + screen = ReceiptsScreen.Detail, + selectedReceiptId = detail.id, + selectedReceipt = detail, + inlineError = null, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun generateReceiptLots() { + val receiptId = uiState.receiptsState.selectedReceiptId ?: return + viewModelScope.launch { + uiState = uiState.copy(receiptsState = uiState.receiptsState.copy(isGeneratingLots = true, inlineError = null)) + try { + val detail = repository.generateReceiptLots(receiptId) + refreshReceipts() + refreshLots() + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isGeneratingLots = false, + selectedReceipt = detail, + ), + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + receiptsState = uiState.receiptsState.copy( + isGeneratingLots = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun selectWashingLot(id: String) { + uiState = uiState.copy(washingState = uiState.washingState.copy(selectedLotId = id)) + } + + fun selectWashingPlace(id: String) { + uiState = uiState.copy(washingState = uiState.washingState.copy(selectedWashingPlaceId = id)) + } + + fun updateWashingCost(value: String) { + uiState = uiState.copy(washingState = uiState.washingState.copy(washingCost = value)) + } + + fun updateWashingDuration(value: String) { + uiState = uiState.copy(washingState = uiState.washingState.copy(durationHours = value)) + } + + fun updateAfterQty(value: String) { + uiState = uiState.copy(washingState = uiState.washingState.copy(afterQty = value)) + } + + fun selectCompleteGrade(id: String?) { + uiState = uiState.copy(washingState = uiState.washingState.copy(selectedGradeId = id)) + } + + fun selectCompleteWarehouse(id: String) { + val warehouse = uiState.washingState.warehouses.find { it.id == id } + uiState = uiState.copy( + washingState = uiState.washingState.copy( + selectedWarehouseId = id, + selectedWarehouseLocationId = warehouse?.locations?.firstOrNull()?.id, + ), + ) + } + + fun selectCompleteWarehouseLocation(id: String?) { + uiState = uiState.copy(washingState = uiState.washingState.copy(selectedWarehouseLocationId = id)) + } + + fun saveWashing() { + val state = uiState.washingState + val lotId = state.selectedLotId + val placeId = state.selectedWashingPlaceId + val cost = state.washingCost.toDoubleOrNull() + val duration = state.durationHours.toIntOrNull() + if (lotId.isNullOrBlank() || placeId.isNullOrBlank() || cost == null || duration == null) { + uiState = uiState.copy( + washingState = uiState.washingState.copy(inlineError = "Lot, tempat cuci, biaya, dan durasi wajib diisi."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy(washingState = uiState.washingState.copy(isSaving = true, inlineError = null)) + try { + val payload = WashingCreatePayload( + lotId = lotId, + washingPlaceId = placeId, + washingCost = cost, + durationHours = duration, + ) + if (state.screen == WashingScreen.Edit && !state.selectedWashingId.isNullOrBlank()) { + repository.updateWashing(state.selectedWashingId, payload) + } else { + repository.createWashing(payload) + } + closeWashingScreen() + refreshWashing() + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + washingState = uiState.washingState.copy( + isSaving = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + fun completeWashing() { + val state = uiState.washingState + val washingId = state.selectedWashingId + val afterQty = state.afterQty.toDoubleOrNull() + val warehouseId = state.selectedWarehouseId + if (washingId.isNullOrBlank() || afterQty == null || warehouseId.isNullOrBlank()) { + uiState = uiState.copy( + washingState = uiState.washingState.copy(inlineError = "After qty dan gudang tujuan wajib diisi."), + ) + return + } + + viewModelScope.launch { + uiState = uiState.copy(washingState = uiState.washingState.copy(isCompleting = true, inlineError = null)) + try { + repository.completeWashing( + id = washingId, + payload = CompleteWashingPayload( + afterQty = afterQty, + gradeId = state.selectedGradeId, + warehouseId = warehouseId, + warehouseLocationId = state.selectedWarehouseLocationId, + ), + ) + closeWashingScreen() + refreshWashing() + refreshLots() + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + if (shouldLogout) { + handleUnauthorized(error) + } else { + uiState = uiState.copy( + washingState = uiState.washingState.copy( + isCompleting = false, + inlineError = error.toReadableMessage(), + ), + ) + } + } + } + } + + private fun updateLotTransformationInput( + index: Int, + transform: (LotTransformationInputFormState) -> LotTransformationInputFormState, + ) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + inputs = uiState.lotTransformationsState.inputs.mapIndexed { currentIndex, item -> + if (currentIndex == index) transform(item) else item + }, + inlineError = null, + ), + ) + } + + private fun updateLotTransformationOutput( + index: Int, + transform: (LotTransformationOutputFormState) -> LotTransformationOutputFormState, + ) { + uiState = uiState.copy( + lotTransformationsState = uiState.lotTransformationsState.copy( + outputs = uiState.lotTransformationsState.outputs.mapIndexed { currentIndex, item -> + if (currentIndex == index) transform(item) else item + }, + inlineError = null, + ), + ) + } + + private fun updatePurchaseLine( + index: Int, + transform: (PurchaseLineFormState) -> PurchaseLineFormState, + ) { + uiState = uiState.copy( + purchasesState = uiState.purchasesState.copy( + lines = uiState.purchasesState.lines.mapIndexed { currentIndex, line -> + if (currentIndex == index) transform(line) else line + }, + inlineError = null, + ), + ) + } + + private fun restoreSession() { + viewModelScope.launch { + if (repository.sessionToken().isNullOrBlank()) { + uiState = MainUiState(isCheckingSession = false) + } else { + loadDashboard(trigger = LoadTrigger.SessionRestore) + } + } + } + + private suspend fun loadDashboard(trigger: LoadTrigger) { + val previousDashboard = uiState.dashboard + uiState = uiState.copy( + isCheckingSession = previousDashboard == null, + isSubmitting = trigger == LoadTrigger.Login, + isRefreshing = trigger == LoadTrigger.Refresh && previousDashboard != null, + errorMessage = null, + ) + try { + val dashboard = repository.loadDashboardBundle() + val nextModule = uiState.currentModule.takeIf { + it == "dashboard" || dashboard.modules.contains(it) + } ?: "dashboard" + + uiState = MainUiState( + isCheckingSession = false, + isSubmitting = false, + isRefreshing = false, + isAuthenticated = true, + dashboard = dashboard, + currentModule = nextModule, + lotsState = uiState.lotsState, + salesRegularState = uiState.salesRegularState, + salesJitState = uiState.salesJitState, + consignmentsState = uiState.consignmentsState, + fundRequestsState = uiState.fundRequestsState, + purchasesState = uiState.purchasesState, + purchaseAnalysesState = uiState.purchaseAnalysesState, + purchaseRealizationsState = uiState.purchaseRealizationsState, + lotTransformationsState = uiState.lotTransformationsState, + stockAdjustmentsState = uiState.stockAdjustmentsState, + washingState = uiState.washingState, + receiptsState = uiState.receiptsState, + ) + } catch (error: Throwable) { + val shouldLogout = error is HttpException && (error.code() == 401 || error.code() == 403) + + if (previousDashboard != null && !shouldLogout) { + uiState = uiState.copy( + isCheckingSession = false, + isSubmitting = false, + isRefreshing = false, + errorMessage = error.toReadableMessage(), + ) + } else { + handleUnauthorized(error) + } + } + } + + private fun handleUnauthorized(error: Throwable) { + repository.logout() + password = "" + uiState = MainUiState( + isCheckingSession = false, + isSubmitting = false, + isRefreshing = false, + isAuthenticated = false, + currentModule = "dashboard", + errorMessage = error.toReadableMessage(), + ) + } + + private enum class LoadTrigger { + SessionRestore, + Login, + Refresh, + } +} + +private fun LotScanResult.payloadToState(): LotDetailData = payload.toLotDetailData() + +private fun id.abelbirdnest.mobile.data.LotScanPayload.toLotDetailData(): LotDetailData { + return LotDetailData( + summaryCard = summaryCard, + lot = lot, + procurement = procurement, + mobileActions = mobileActions, + ) +} + +private fun formatQuantityInput(value: Double): String { + return if (value % 1.0 == 0.0) value.roundToInt().toString() else value.toString() +} + +private fun ReceiptPurchaseLineOption.toFormState(): ReceiptLineFormState { + return ReceiptLineFormState( + purchaseLineId = purchaseLineId, + gradeId = grade?.id, + gradeName = grade?.name ?: "-", + qtyOrdered = formatQuantityInput(qtyOrdered), + qtyReceived = formatQuantityInput(qtyReceived), + qtyAccepted = formatQuantityInput(qtyAccepted), + qtyRejected = formatQuantityInput(qtyRejected), + unitId = unit.id, + unitCode = unit.code, + unitCost = formatQuantityInput(unitCost), + warehouseId = warehouseId.orEmpty(), + warehouseLocationId = warehouseLocationId.orEmpty(), + notes = "", + ) +} + +private fun RegularSaleLineDetail.toCloseFormState(): RegularSaleCloseLineFormState { + return RegularSaleCloseLineFormState( + lineId = id, + lotCode = lotCode, + qtyPlanned = qtyPlanned, + qtyActualSold = formatQuantityInput(qtyActualSold ?: qtyPlanned), + qtyReturned = formatQuantityInput(qtyReturned ?: 0.0), + sellingPriceActual = formatQuantityInput(sellingPriceActual ?: sellingPricePlanned), + ) +} + +private fun JitSaleLineDetail.toCloseFormState(): JitSaleCloseLineFormState { + return JitSaleCloseLineFormState( + lineId = id, + gradeName = grade.name, + qtyPlanned = qtyPlanned, + sellingPriceActual = formatQuantityInput(sellingPriceActual ?: sellingPricePlanned), + ) +} + +private fun todayIsoDate(): String = java.time.LocalDate.now().toString() + +private fun currentIsoDateTimeLocal(): String = java.time.LocalDateTime.now().withSecond(0).withNano(0).toString() + +private fun defaultTransformationTypes(): List = listOf( + CodeLabelOption(code = "MIX", label = "Mixing"), + CodeLabelOption(code = "REGRADE", label = "Regrade"), +) + +private fun defaultRemainderModes(): List = listOf( + CodeLabelOption(code = "KEEP_SOURCE_GRADE", label = "Simpan sisa di lot sumber"), + CodeLabelOption(code = "SHRINKAGE", label = "Catat sebagai shrinkage"), +) + +private fun defaultProcessingLossModes(): List = listOf( + CodeLabelOption(code = "SHRINKAGE", label = "Catat sebagai shrinkage"), +) + +private fun Throwable.toReadableMessage(): String { + return when (this) { + is HttpException -> "Login atau pengambilan data gagal (${code()})." + is IOException -> "Tidak bisa terhubung ke server. Periksa koneksi internet." + else -> message ?: "Terjadi kesalahan yang tidak terduga." + } +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Color.kt b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Color.kt new file mode 100644 index 0000000..cfddda9 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Color.kt @@ -0,0 +1,21 @@ +package id.abelbirdnest.mobile.ui.theme + +import androidx.compose.ui.graphics.Color + +val Background = Color(0xFFF8FAFA) +val Surface = Color(0xFFF8FAFA) +val SurfaceContainerLowest = Color(0xFFFFFFFF) +val SurfaceContainerHigh = Color(0xFFE6E8E9) +val SurfaceContainer = Color(0xFFECEEEE) +val OnSurface = Color(0xFF191C1D) +val OnSurfaceVariant = Color(0xFF3F484A) +val Outline = Color(0xFF6F797A) +val OutlineVariant = Color(0xFFBFC8CA) +val Primary = Color(0xFF00454C) +val PrimaryContainer = Color(0xFF0D5E67) +val OnPrimary = Color(0xFFFFFFFF) +val OnPrimaryContainer = Color(0xFF92D5DF) +val Secondary = Color(0xFF4E6073) +val SecondaryContainer = Color(0xFFCFE2F9) +val Tertiary = Color(0xFF60320F) +val Error = Color(0xFFBA1A1A) diff --git a/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Theme.kt b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Theme.kt new file mode 100644 index 0000000..d80ed8d --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Theme.kt @@ -0,0 +1,87 @@ +package id.abelbirdnest.mobile.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +private val AppColorScheme: ColorScheme = lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + primaryContainer = PrimaryContainer, + onPrimaryContainer = OnPrimaryContainer, + secondary = Secondary, + secondaryContainer = SecondaryContainer, + tertiary = Tertiary, + error = Error, + background = Background, + surface = Surface, + surfaceContainer = SurfaceContainer, + surfaceContainerHigh = SurfaceContainerHigh, + surfaceContainerLowest = SurfaceContainerLowest, + onSurface = OnSurface, + onSurfaceVariant = OnSurfaceVariant, + outline = Outline, + outlineVariant = OutlineVariant, +) + +private val AppTypography = Typography( + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 32.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 30.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 18.sp, + ), + labelSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.6.sp, + ), + displaySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp, + ), +) + +@Composable +fun AbelbirdnestTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = AppColorScheme, + typography = AppTypography, + content = content, + ) +} diff --git a/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Type.kt b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Type.kt new file mode 100644 index 0000000..7809044 --- /dev/null +++ b/app/src/main/java/id/abelbirdnest/mobile/ui/theme/Type.kt @@ -0,0 +1,3 @@ +package id.abelbirdnest.mobile.ui.theme + +// Typography is defined in Theme.kt. diff --git a/app/src/main/res/drawable/logo_abelbirdnest.png b/app/src/main/res/drawable/logo_abelbirdnest.png new file mode 100755 index 0000000..1746601 Binary files /dev/null and b/app/src/main/res/drawable/logo_abelbirdnest.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..edd8958 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Abelbirdnest Stock + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1b4481a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fb45c1c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.java.home=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..74a4ead --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/logo_abelbirdnest.png b/logo_abelbirdnest.png new file mode 100755 index 0000000..1746601 Binary files /dev/null and b/logo_abelbirdnest.png differ diff --git a/mobile-api-blueprint.md b/mobile-api-blueprint.md new file mode 100644 index 0000000..dbdc856 --- /dev/null +++ b/mobile-api-blueprint.md @@ -0,0 +1,268 @@ +# Mobile API Blueprint + +Dokumen ini merangkum: +- endpoint mobile per role +- layar minimum yang dibutuhkan aplikasi mobile +- batas scope mobile yang sengaja dipertahankan agar tetap praktis + +## Prinsip + +- Mobile memakai auth yang sama dengan web. +- Login lewat `POST /api/v1/auth/login`, lalu kirim `Authorization: Bearer `. +- Semua endpoint mobile berada di prefix `/api/v1/mobile`. +- Mobile fokus ke operasi cepat, scan, input lapangan, monitoring, dan closing ringan. +- Fitur admin berat seperti `users`, `settings`, `audit-trail`, dan master data lengkap tetap web-only. + +## Role Mobile + +Role yang didukung di mobile: +- `WAREHOUSE` +- `QC` +- `SALES` +- `PURCHASING` +- `OWNER` + +Role yang tidak menjadi target mobile utama: +- `ADMIN` +- `SYSTEM_ADMIN` + +## Bootstrap Umum + +Semua role mobile memakai: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard?locale=id|en` + +`/mobile/bootstrap` mengembalikan: +- user session +- daftar modul yang boleh diakses role tersebut +- summary ringkas operasional +- grade aktif +- gudang aktif dan lokasi aktif +- master transformation mode + +## Matriks Endpoint Per Role + +### Warehouse + +Tujuan: +- scan lot +- submit purchase yang otomatis membuat receipt dan lot +- buat penyesuaian stok +- kirim / selesaikan washing + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/purchases` +- `GET /api/v1/mobile/purchases/:id` +- `POST /api/v1/mobile/purchases/:id/submit` +- `GET /api/v1/mobile/stock-adjustments/bootstrap` +- `GET /api/v1/mobile/stock-adjustments` +- `POST /api/v1/mobile/stock-adjustments` +- `GET /api/v1/mobile/washing/bootstrap` +- `GET /api/v1/mobile/washing` +- `POST /api/v1/mobile/washing` +- `PUT /api/v1/mobile/washing/:id` +- `POST /api/v1/mobile/washing/:id/complete` + +### QC + +Tujuan: +- scan lot +- lihat detail lineage lot +- ubah grade / mixing +- bantu washing completion +- buat penyesuaian stok terkait QC + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/washing/bootstrap` +- `GET /api/v1/mobile/washing` +- `PUT /api/v1/mobile/washing/:id` +- `POST /api/v1/mobile/washing/:id/complete` +- `GET /api/v1/mobile/lot-transformations` +- `POST /api/v1/mobile/lot-transformations` +- `GET /api/v1/mobile/lot-transformations/:id` +- `GET /api/v1/mobile/stock-adjustments/bootstrap` +- `GET /api/v1/mobile/stock-adjustments` +- `POST /api/v1/mobile/stock-adjustments` + +### Sales + +Tujuan: +- lihat stok jual +- buat penjualan reguler +- buat penjualan JIT +- buat titip jual +- tutup transaksi + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/lots` +- `GET /api/v1/mobile/lots/:id` +- `GET /api/v1/mobile/lots/scan?code=...` +- `GET /api/v1/mobile/sales-regular/bootstrap` +- `GET /api/v1/mobile/sales-regular` +- `POST /api/v1/mobile/sales-regular` +- `GET /api/v1/mobile/sales-regular/:id` +- `POST /api/v1/mobile/sales-regular/:id/close` +- `GET /api/v1/mobile/sales-jit/bootstrap` +- `GET /api/v1/mobile/sales-jit` +- `POST /api/v1/mobile/sales-jit` +- `GET /api/v1/mobile/sales-jit/:id` +- `POST /api/v1/mobile/sales-jit/:id/close` +- `GET /api/v1/mobile/consignments/bootstrap` +- `GET /api/v1/mobile/consignments` +- `POST /api/v1/mobile/consignments` +- `GET /api/v1/mobile/consignments/:id` +- `POST /api/v1/mobile/consignments/lines/:lineId/close` + +### Purchasing + +Tujuan: +- buat draft pembelian +- edit / submit pembelian +- buat permintaan dana +- monitor analisis dan realisasi pembelian + +Endpoint: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/purchases` +- `POST /api/v1/mobile/purchases` +- `GET /api/v1/mobile/purchases/:id` +- `PUT /api/v1/mobile/purchases/:id` +- `POST /api/v1/mobile/purchases/:id/submit` +- `POST /api/v1/mobile/purchases/:id/cancel` +- `GET /api/v1/mobile/fund-requests/bootstrap` +- `GET /api/v1/mobile/fund-requests` +- `POST /api/v1/mobile/fund-requests` +- `GET /api/v1/mobile/purchase-analyses` +- `GET /api/v1/mobile/purchase-analyses/:purchaseId` +- `GET /api/v1/mobile/purchase-realizations` +- `GET /api/v1/mobile/purchase-realizations/:purchaseId` + +### Owner + +Tujuan: +- monitoring dashboard +- lihat analisis pembelian +- lihat realisasi pembelian +- lihat status transaksi penting + +Endpoint minimum: +- `GET /api/v1/mobile/bootstrap` +- `GET /api/v1/mobile/dashboard` +- `GET /api/v1/mobile/purchases` +- `GET /api/v1/mobile/purchases/:id` +- `GET /api/v1/mobile/fund-requests` +- `GET /api/v1/mobile/purchase-analyses` +- `GET /api/v1/mobile/purchase-analyses/:purchaseId` +- `GET /api/v1/mobile/purchase-realizations` +- `GET /api/v1/mobile/purchase-realizations/:purchaseId` +- `GET /api/v1/mobile/sales-regular` +- `GET /api/v1/mobile/sales-jit` +- `GET /api/v1/mobile/consignments` +- `GET /api/v1/mobile/washing` + +## Layar Minimum Per Role + +### Warehouse + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Scan lot +4. Detail lot +5. Daftar pembelian siap submit +6. Submit pembelian yang otomatis membuat receipt + lot +7. Penyesuaian stok +8. Daftar washing +9. Buat washing +10. Selesaikan washing + +### QC + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Scan lot +4. Detail lot dan turunan lot +5. Daftar transformasi +6. Buat mixing / ubah grade +7. Daftar washing +8. Selesaikan washing +9. Penyesuaian stok QC + +### Sales + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Stok siap jual +4. Scan lot +5. Buat penjualan reguler +6. Detail dan tutup penjualan reguler +7. Buat penjualan JIT +8. Detail dan tutup penjualan JIT +9. Buat titip jual +10. Detail dan tutup item titip jual + +### Purchasing + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Daftar pembelian +4. Form draft pembelian +5. Detail pembelian +6. Submit pembelian +7. Daftar permintaan dana +8. Form permintaan dana +9. Daftar analisis pembelian +10. Daftar realisasi pembelian + +### Owner + +Layar minimum: +1. Login +2. Dashboard ringkas +3. Daftar pembelian +4. Detail pembelian +5. Daftar analisis pembelian +6. Detail analisis pembelian +7. Daftar realisasi pembelian +8. Detail realisasi pembelian +9. Daftar transaksi keluar +10. Daftar washing + +## Scope Yang Sengaja Tidak Dibawa ke Mobile + +- manajemen user +- pengaturan sistem +- audit trail penuh +- master data lengkap +- office buyout +- print label / dokumen +- laporan web yang kompleks + +## Urutan Implementasi UI Mobile yang Disarankan + +1. Warehouse +2. QC +3. Sales +4. Purchasing +5. Owner + +Alasannya: +- Warehouse dan QC paling sering butuh scan dan aksi lapangan cepat +- Sales di urutan berikutnya karena butuh transaksi tapi tidak banyak input master +- Purchasing dan Owner lebih banyak monitoring dan persetujuan ringan diff --git a/postman/abelbirdnest-mobile-api.postman_collection.json b/postman/abelbirdnest-mobile-api.postman_collection.json new file mode 100644 index 0000000..5a6023f --- /dev/null +++ b/postman/abelbirdnest-mobile-api.postman_collection.json @@ -0,0 +1,569 @@ +{ + "info": { + "name": "AbelBirdnest Mobile Operations API", + "description": "Collection Postman untuk integrasi mobile Warehouse, QC, Sales, Purchasing, dan Owner. Gunakan Login terlebih dahulu untuk mengisi {{sessionToken}}.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:3000/api/v1" }, + { "key": "sessionToken", "value": "" }, + { "key": "purchaseId", "value": "" }, + { "key": "receiptId", "value": "" }, + { "key": "lotId", "value": "" }, + { "key": "washingId", "value": "" }, + { "key": "regularSaleId", "value": "" }, + { "key": "jitSaleId", "value": "" }, + { "key": "consignmentId", "value": "" }, + { "key": "consignmentLineId", "value": "" }, + { "key": "purchaseAnalysisId", "value": "" }, + { "key": "purchaseRealizationId", "value": "" } + ], + "auth": { + "type": "bearer", + "bearer": [ + { "key": "token", "value": "{{sessionToken}}", "type": "string" } + ] + }, + "item": [ + { + "name": "1. Auth", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const token = json?.data?.session_token;", + " const role = json?.data?.user?.role;", + " if (token) pm.collectionVariables.set('sessionToken', token);", + " if (role) pm.collectionVariables.set('userRole', role);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"identity\": \"admin\",\n \"password\": \"admin123\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": ["{{baseUrl}}"], + "path": ["auth", "login"] + } + } + }, + { + "name": "Session Me", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/auth/me", + "host": ["{{baseUrl}}"], + "path": ["auth", "me"] + } + } + } + ] + }, + { + "name": "2. Bootstrap & Dashboard", + "item": [ + { + "name": "Mobile Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "bootstrap"] + } + } + }, + { + "name": "Mobile Dashboard", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/dashboard?locale=id", + "host": ["{{baseUrl}}"], + "path": ["mobile", "dashboard"], + "query": [ + { "key": "locale", "value": "id" } + ] + } + } + } + ] + }, + { + "name": "3. Warehouse & QC", + "item": [ + { + "name": "Lot List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots"] + } + } + }, + { + "name": "Lot Scan", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots/scan?code=LOT-EXAMPLE-001", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots", "scan"], + "query": [ + { "key": "code", "value": "LOT-EXAMPLE-001" } + ] + } + } + }, + { + "name": "Lot Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lots/{{lotId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lots", "{{lotId}}"] + } + } + }, + { + "name": "Receipt Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/receipts/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts", "bootstrap"] + } + } + }, + { + "name": "Receipt List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/receipts", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts"] + } + } + }, + { + "name": "Create Receipt", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"purchase_id\": \"{{purchaseId}}\",\n \"receipt_date\": \"2026-05-16\",\n \"notes\": \"Receipt dari mobile\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/receipts", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts"] + } + } + }, + { + "name": "Generate Lots From Receipt", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/mobile/receipts/{{receiptId}}/generate-lots", + "host": ["{{baseUrl}}"], + "path": ["mobile", "receipts", "{{receiptId}}", "generate-lots"] + } + } + }, + { + "name": "Stock Adjustment Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/stock-adjustments/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "stock-adjustments", "bootstrap"] + } + } + }, + { + "name": "Create Stock Adjustment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"lot_id\": \"{{lotId}}\",\n \"adjustment_reason_id\": \"\",\n \"adjustment_date\": \"2026-05-16\",\n \"qty_change\": -1,\n \"notes\": \"Mobile adjustment\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/stock-adjustments", + "host": ["{{baseUrl}}"], + "path": ["mobile", "stock-adjustments"] + } + } + }, + { + "name": "Washing Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/washing/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing", "bootstrap"] + } + } + }, + { + "name": "Washing List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/washing", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing"] + } + } + }, + { + "name": "Create Washing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"lot_id\": \"{{lotId}}\",\n \"washing_place_id\": \"\",\n \"washing_cost\": 10000,\n \"duration_hours\": 24\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/washing", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing"] + } + } + }, + { + "name": "Complete Washing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"after_qty\": 1,\n \"grade_id\": \"\",\n \"warehouse_id\": \"\",\n \"warehouse_location_id\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/washing/{{washingId}}/complete", + "host": ["{{baseUrl}}"], + "path": ["mobile", "washing", "{{washingId}}", "complete"] + } + } + }, + { + "name": "Transformation List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/lot-transformations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lot-transformations"] + } + } + }, + { + "name": "Create Lot Transformation", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"transformation_type\": \"MIX\",\n \"transformation_date\": \"2026-05-16\",\n \"remainder_mode\": \"KEEP_SOURCE_GRADE\",\n \"processing_loss_mode\": \"SHRINKAGE\",\n \"notes\": \"Mobile transformation\",\n \"inputs\": [\n {\n \"source_lot_code\": \"LOT-EXAMPLE-001\",\n \"qty_used\": 1\n }\n ],\n \"outputs\": [\n {\n \"grade_id\": \"\",\n \"warehouse_id\": \"\",\n \"warehouse_location_id\": \"\",\n \"qty_produced\": 1\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/lot-transformations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "lot-transformations"] + } + } + } + ] + }, + { + "name": "4. Sales", + "item": [ + { + "name": "Regular Sales Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "bootstrap"] + } + } + }, + { + "name": "Regular Sales List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular"] + } + } + }, + { + "name": "Regular Sale Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/{{regularSaleId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "{{regularSaleId}}"] + } + } + }, + { + "name": "Close Regular Sale", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/sales-regular/{{regularSaleId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-regular", "{{regularSaleId}}", "close"] + } + } + }, + { + "name": "JIT Sales Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit", "bootstrap"] + } + } + }, + { + "name": "JIT Sales List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit"] + } + } + }, + { + "name": "Close JIT Sale", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"lines\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/sales-jit/{{jitSaleId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "sales-jit", "{{jitSaleId}}", "close"] + } + } + }, + { + "name": "Consignments Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "bootstrap"] + } + } + }, + { + "name": "Consignments List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments"] + } + } + }, + { + "name": "Consignment Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/consignments/{{consignmentId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "{{consignmentId}}"] + } + } + }, + { + "name": "Close Consignment Line", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"close_date\": \"2026-05-16\",\n \"qty_sold\": 1,\n \"qty_returned\": 0,\n \"selling_price\": 100000,\n \"sales_commission\": 0\n}" + }, + "url": { + "raw": "{{baseUrl}}/mobile/consignments/lines/{{consignmentLineId}}/close", + "host": ["{{baseUrl}}"], + "path": ["mobile", "consignments", "lines", "{{consignmentLineId}}", "close"] + } + } + } + ] + }, + { + "name": "5. Purchasing & Owner", + "item": [ + { + "name": "Purchases List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchases", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases"] + } + } + }, + { + "name": "Purchase Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchases/{{purchaseId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases", "{{purchaseId}}"] + } + } + }, + { + "name": "Submit Purchase", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/mobile/purchases/{{purchaseId}}/submit", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchases", "{{purchaseId}}", "submit"] + } + } + }, + { + "name": "Fund Requests Bootstrap", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/fund-requests/bootstrap", + "host": ["{{baseUrl}}"], + "path": ["mobile", "fund-requests", "bootstrap"] + } + } + }, + { + "name": "Fund Requests List", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/fund-requests", + "host": ["{{baseUrl}}"], + "path": ["mobile", "fund-requests"] + } + } + }, + { + "name": "Purchase Analyses", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-analyses", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-analyses"] + } + } + }, + { + "name": "Purchase Analysis Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-analyses/{{purchaseAnalysisId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-analyses", "{{purchaseAnalysisId}}"] + } + } + }, + { + "name": "Purchase Realizations", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-realizations", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-realizations"] + } + } + }, + { + "name": "Purchase Realization Detail", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/mobile/purchase-realizations/{{purchaseRealizationId}}", + "host": ["{{baseUrl}}"], + "path": ["mobile", "purchase-realizations", "{{purchaseRealizationId}}"] + } + } + } + ] + } + ] +} diff --git a/postman/abelbirdnest-mobile-local.postman_environment.json b/postman/abelbirdnest-mobile-local.postman_environment.json new file mode 100644 index 0000000..5abc9e7 --- /dev/null +++ b/postman/abelbirdnest-mobile-local.postman_environment.json @@ -0,0 +1,93 @@ +{ + "id": "e5db42ce-7b3a-4d72-8df7-abelbirdnest-mobile-local", + "name": "AbelBirdnest Mobile Local", + "values": [ + { + "key": "baseUrl", + "value": "https://abelbirdnest.id/api/v1", + "type": "text", + "enabled": true + }, + { + "key": "sessionToken", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "defaultRedirectTo", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "userRole", + "value": "", + "type": "text", + "enabled": true + }, + { + "key": "scanCode", + "value": "LOT-260501-MIX-001", + "type": "text", + "enabled": true + }, + { + "key": "lotId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "transformationId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemTypeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "itemGradeId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "targetItemGradeId", + "value": "2", + "type": "text", + "enabled": true + }, + { + "key": "warehouseId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "warehouseLocationId", + "value": "1", + "type": "text", + "enabled": true + }, + { + "key": "sourceLotCode1", + "value": "LOT-260501-MIX-001", + "type": "text", + "enabled": true + }, + { + "key": "sourceLotCode2", + "value": "LOT-260501-MIX-002", + "type": "text", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-05-02T06:05:00+07:00", + "_postman_exported_using": "Codex GPT-5" +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f2283bb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Abelbirdnest Stock" +include(":app")