Files
AbelBirdNest-mobile/app/src/main/java/id/abelbirdnest/mobile/ui/AbelbirdnestApp.kt

7668 lines
341 KiB
Kotlin

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<QuickAction>,
bottomItems: List<String>,
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<QuickAction>,
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<CriticalLot>) {
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<GradeDistribution>) {
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<String>,
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<WarehouseLookup>,
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<ReceiptDetailLine>) {
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<ReceiptGeneratedLot>) {
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<RegularSaleLineDetail>) {
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<RegularSaleLineDetail>,
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<JitSaleLineDetail>) {
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<ConsignmentLineDetail>,
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<LookupRecord>,
units: List<UnitLookup>,
warehouses: List<WarehouseLookup>,
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<PurchaseLineDetail>) {
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<LotItem>,
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<LookupRecord>,
warehouses: List<WarehouseLookup>,
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<LotTransformationInput>) {
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<LotTransformationOutput>) {
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<Pair<String, String>>,
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<String?>(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<String>,
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<String>): List<QuickAction> {
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<String>): List<String> {
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<String> = 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<String>): List<String> {
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<LotTimelineEvent> {
val summary = detail.summaryCardOrFallback()
val timeline = mutableListOf<LotTimelineEvent>()
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()
}
}