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() } }