7668 lines
341 KiB
Kotlin
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()
|
|
}
|
|
}
|