Compare commits

...

2 Commits

Author SHA1 Message Date
androidlover5842
9ac0b55b89 show guest docs images 2026-01-31 00:16:12 +05:30
androidlover5842
4fc080f146 admins can delete cash payments 2026-01-30 11:40:49 +05:30
13 changed files with 739 additions and 25 deletions

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature <uses-feature

View File

@@ -91,6 +91,9 @@ class MainActivity : ComponentActivity() {
it == "ADMIN" || it == "MANAGER" it == "ADMIN" || it == "MANAGER"
} == true } == true
} }
val canDeleteCashPayment: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true
}
BackHandler(enabled = currentRoute != AppRoute.Home) { BackHandler(enabled = currentRoute != AppRoute.Home) {
when (currentRoute) { when (currentRoute) {
@@ -480,12 +483,14 @@ class MainActivity : ComponentActivity() {
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId bookingId = currentRoute.bookingId
) )
} },
canManageDocuments = canManagePayuSettings(currentRoute.propertyId)
) )
is AppRoute.BookingPayments -> BookingPaymentsScreen( is AppRoute.BookingPayments -> BookingPaymentsScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
canAddCash = canManagePayuSettings(currentRoute.propertyId), canAddCash = canManagePayuSettings(currentRoute.propertyId),
canDeleteCash = canDeleteCashPayment(currentRoute.propertyId),
onBack = { onBack = {
route.value = AppRoute.BookingDetailsTabs( route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId, currentRoute.propertyId,

View File

@@ -23,6 +23,7 @@ import com.android.trisolarispms.data.api.model.PaymentCreateRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.DELETE
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -128,4 +129,11 @@ interface BookingApi {
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: PaymentCreateRequest @Body body: PaymentCreateRequest
): Response<PaymentDto> ): Response<PaymentDto>
@DELETE("properties/{propertyId}/bookings/{bookingId}/payments/{paymentId}")
suspend fun deletePayment(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("paymentId") paymentId: String
): Response<Unit>
} }

View File

@@ -7,9 +7,11 @@ import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.DELETE
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming import retrofit2.http.Streaming
interface GuestDocumentApi { interface GuestDocumentApi {
@@ -22,6 +24,15 @@ interface GuestDocumentApi {
@Part("bookingId") bookingId: RequestBody @Part("bookingId") bookingId: RequestBody
): Response<GuestDocumentDto> ): Response<GuestDocumentDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/documents")
suspend fun uploadGuestDocumentWithBooking(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Query("bookingId") bookingId: String,
@Part file: MultipartBody.Part
): Response<GuestDocumentDto>
@GET("properties/{propertyId}/guests/{guestId}/documents") @GET("properties/{propertyId}/guests/{guestId}/documents")
suspend fun listGuestDocuments( suspend fun listGuestDocuments(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -35,4 +46,11 @@ interface GuestDocumentApi {
@Path("guestId") guestId: String, @Path("guestId") guestId: String,
@Path("documentId") documentId: String @Path("documentId") documentId: String
): Response<ResponseBody> ): Response<ResponseBody>
@DELETE("properties/{propertyId}/guests/{guestId}/documents/{documentId}")
suspend fun deleteGuestDocument(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Path("documentId") documentId: String
): Response<Unit>
} }

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.guestdocs
import com.android.trisolarispms.data.api.model.GuestDocumentDto
data class GuestDocumentsState(
val isLoading: Boolean = false,
val isUploading: Boolean = false,
val error: String? = null,
val documents: List<GuestDocumentDto> = emptyList()
)

View File

@@ -0,0 +1,341 @@
package com.android.trisolarispms.ui.guestdocs
import android.graphics.Bitmap
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.ImageLoader
import coil.compose.SubcomposeAsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import com.android.trisolarispms.data.api.ApiConstants
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import com.google.firebase.auth.FirebaseAuth
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.io.File
import java.io.FileOutputStream
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.graphics.Brush
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestDocumentsTab(
propertyId: String,
guestId: String,
bookingId: String,
canManageDocuments: Boolean,
viewModel: GuestDocumentsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val showPicker = remember { mutableStateOf(false) }
val auth = remember { FirebaseAuth.getInstance() }
val tokenProvider = remember { FirebaseAuthTokenProvider(auth) }
val imageLoader = remember {
ImageLoader.Builder(context)
.components { add(SvgDecoder.Factory()) }
.okHttpClient(
OkHttpClient.Builder()
.addInterceptor(Interceptor { chain ->
val original = chain.request()
val token = runCatching {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
}.getOrNull()
if (token.isNullOrBlank()) {
chain.proceed(original)
} else {
val request = original.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
})
.build()
)
.build()
}
LaunchedEffect(propertyId, guestId) {
viewModel.load(propertyId, guestId)
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicturePreview()
) { bitmap: Bitmap? ->
if (bitmap != null) {
val file = writeBitmapToCache(bitmap, context.cacheDir)
viewModel.uploadFromFile(
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
file = file,
mimeType = "image/jpeg"
)
}
}
val uploadLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
if (uris.isNotEmpty()) {
viewModel.uploadFromUris(
context = context,
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
uris = uris
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
if (state.isUploading) {
Text(text = "Uploading...", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!canManageDocuments) {
Text(text = "You don't have access to view documents.")
return@Column
}
if (!state.isLoading && state.documents.isEmpty()) {
Text(text = "No documents yet")
}
val imageDocs = state.documents.filter {
it.contentType?.lowercase()?.startsWith("image/") == true
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
if (state.isLoading && imageDocs.isEmpty()) {
items(4) {
ShimmerTile()
}
}
items(imageDocs) { doc ->
DocumentImageTile(
propertyId = propertyId,
guestId = guestId,
doc = doc,
imageLoader = imageLoader,
canDelete = canManageDocuments,
onDelete = { documentId ->
viewModel.deleteDocument(propertyId, guestId, documentId)
}
)
}
}
}
if (canManageDocuments) {
FloatingActionButton(
onClick = { showPicker.value = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
androidx.compose.material3.Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add document"
)
}
}
}
if (showPicker.value) {
AlertDialog(
onDismissRequest = { showPicker.value = false },
title = { Text("Add document") },
text = { Text("Choose camera or upload") },
confirmButton = {
TextButton(
onClick = {
showPicker.value = false
cameraLauncher.launch(null)
}
) { Text("Camera") }
},
dismissButton = {
TextButton(
onClick = {
showPicker.value = false
uploadLauncher.launch(arrayOf("*/*"))
}
) { Text("Upload") }
}
)
}
}
@Composable
private fun DocumentImageTile(
propertyId: String,
guestId: String,
doc: GuestDocumentDto,
imageLoader: ImageLoader,
canDelete: Boolean,
onDelete: (String) -> Unit
) {
val context = LocalContext.current
val documentId = doc.id ?: return
val baseUrl = ApiConstants.BASE_URL.trimEnd('/')
val url = "$baseUrl/properties/$propertyId/guests/$guestId/documents/$documentId/file"
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp))
) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(context).data(url).build(),
imageLoader = imageLoader,
contentDescription = "Document image",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
loading = { ShimmerOverlay() },
error = { ShimmerOverlay() }
)
if (canDelete) {
IconButton(
onClick = { onDelete(documentId) },
modifier = Modifier.align(Alignment.TopEnd)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete document",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
val docType = doc.extractedData?.get("docType")
if (!docType.isNullOrBlank()) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = docType,
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
private fun writeBitmapToCache(bitmap: Bitmap, cacheDir: File): File {
val file = File(cacheDir, "doc_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 92, stream)
}
return file
}
@Composable
private fun ShimmerTile() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp))
) {
ShimmerOverlay()
}
}
@Composable
private fun ShimmerOverlay() {
val transition = rememberInfiniteTransition(label = "shimmer")
val x = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerX"
)
val brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
MaterialTheme.colorScheme.surfaceVariant
),
start = androidx.compose.ui.geometry.Offset(x.value - 200f, 0f),
end = androidx.compose.ui.geometry.Offset(x.value, 200f)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(brush)
)
}

View File

@@ -0,0 +1,216 @@
package com.android.trisolarispms.ui.guestdocs
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.GuestDocumentDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
class GuestDocumentsViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestDocumentsState())
val state: StateFlow<GuestDocumentsState> = _state
fun load(propertyId: String, guestId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listGuestDocuments(propertyId, guestId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
documents = body,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Load failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Load failed"
)
}
}
}
}
fun uploadFromUri(
context: Context,
propertyId: String,
guestId: String,
bookingId: String,
uri: Uri
) {
uploadFromUris(context, propertyId, guestId, bookingId, listOf(uri))
}
fun uploadFromUris(
context: Context,
propertyId: String,
guestId: String,
bookingId: String,
uris: List<Uri>
) {
if (uris.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
var errorMessage: String? = null
val resolver = context.contentResolver
for (uri in uris) {
try {
val mime = resolver.getType(uri) ?: "application/octet-stream"
if (mime.lowercase(Locale.getDefault()).startsWith("video/")) {
errorMessage = "Video files not allowed"
continue
}
val filename = resolveFileName(resolver, uri) ?: "document"
val file = copyToCache(resolver, uri, context.cacheDir, filename)
uploadFile(propertyId, guestId, bookingId, file, mime)
} catch (e: Exception) {
errorMessage = e.localizedMessage ?: "Upload failed"
}
}
_state.update {
it.copy(
isUploading = false,
error = errorMessage
)
}
}
}
fun deleteDocument(propertyId: String, guestId: String, documentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deleteGuestDocument(propertyId, guestId, documentId)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
documents = current.documents.filterNot { it.id == documentId },
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Delete failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed"
)
}
}
}
}
fun uploadFromFile(
propertyId: String,
guestId: String,
bookingId: String,
file: File,
mimeType: String
) {
viewModelScope.launch {
_state.update { it.copy(isUploading = true, error = null) }
try {
uploadFile(propertyId, guestId, bookingId, file, mimeType)
} catch (e: Exception) {
_state.update {
it.copy(
isUploading = false,
error = e.localizedMessage ?: "Upload failed"
)
}
}
}
}
private suspend fun uploadFile(
propertyId: String,
guestId: String,
bookingId: String,
file: File,
mimeType: String
) {
val requestBody = file.asRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val api = ApiClient.create()
val response = api.uploadGuestDocumentWithBooking(
propertyId = propertyId,
guestId = guestId,
bookingId = bookingId,
file = part
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
current.copy(
isUploading = false,
documents = listOf(body) + current.documents,
error = null
)
}
} else {
_state.update {
it.copy(
isUploading = false,
error = "Upload failed: ${response.code()}"
)
}
}
}
private fun resolveFileName(resolver: ContentResolver, uri: Uri): String? {
val cursor = resolver.query(uri, null, null, null, null) ?: return null
return cursor.use {
val nameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && it.moveToFirst()) it.getString(nameIndex) else null
}
}
private fun copyToCache(
resolver: ContentResolver,
uri: Uri,
cacheDir: File,
filename: String
): File {
val output = File(cacheDir, "${System.currentTimeMillis()}_$filename")
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(output).use { outputStream ->
input.copyTo(outputStream)
}
}
return output
}
}

View File

@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.payment
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -12,6 +13,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -31,6 +33,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -45,6 +48,7 @@ fun BookingPaymentsScreen(
propertyId: String, propertyId: String,
bookingId: String, bookingId: String,
canAddCash: Boolean, canAddCash: Boolean,
canDeleteCash: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: BookingPaymentsViewModel = viewModel() viewModel: BookingPaymentsViewModel = viewModel()
) { ) {
@@ -87,6 +91,7 @@ fun BookingPaymentsScreen(
onClick = { onClick = {
val amount = amountInput.value.toLongOrNull() ?: 0L val amount = amountInput.value.toLongOrNull() ?: 0L
viewModel.addCashPayment(propertyId, bookingId, amount) viewModel.addCashPayment(propertyId, bookingId, amount)
amountInput.value = ""
}, },
enabled = !state.isLoading, enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -108,10 +113,18 @@ fun BookingPaymentsScreen(
} }
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { ) {
items(state.payments) { payment -> items(state.payments) { payment ->
PaymentCard(payment = payment) PaymentCard(
payment = payment,
canDeleteCash = canDeleteCash,
onDelete = { paymentId ->
viewModel.deleteCashPayment(propertyId, bookingId, paymentId)
}
)
} }
} }
} }
@@ -119,7 +132,11 @@ fun BookingPaymentsScreen(
} }
@Composable @Composable
private fun PaymentCard(payment: PaymentDto) { private fun PaymentCard(
payment: PaymentDto,
canDeleteCash: Boolean,
onDelete: (String) -> Unit
) {
val date = payment.receivedAt?.let { val date = payment.receivedAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull() runCatching { OffsetDateTime.parse(it) }.getOrNull()
} }
@@ -136,19 +153,52 @@ private fun PaymentCard(payment: PaymentDto) {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val amountText = buildString { val amountText = buildString {
append(payment.amount?.toString() ?: "-") append(payment.amount?.toString() ?: "-")
payment.currency?.let { append(" $it") } payment.currency?.let { append(" $it") }
} }
Text(text = amountText, style = MaterialTheme.typography.titleMedium) Text(text = amountText, style = MaterialTheme.typography.titleMedium)
if (canDeleteCash && isCash && !payment.id.isNullOrBlank()) {
IconButton(onClick = { onDelete(payment.id) }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete cash payment"
)
}
}
}
val methodColor = if (isCash) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.secondary
}
val upiColor = MaterialTheme.colorScheme.primary
val payerColor = MaterialTheme.colorScheme.secondary
payment.method?.let { payment.method?.let {
Text(text = "Method: $it", style = MaterialTheme.typography.bodySmall) Text(
text = "Method: $it",
style = MaterialTheme.typography.bodySmall,
color = methodColor
)
} }
payment.payerVpa?.takeIf { it.isNotBlank() }?.let { payment.payerVpa?.takeIf { it.isNotBlank() }?.let {
Text(text = "UPI: $it", style = MaterialTheme.typography.bodySmall) Text(
text = "UPI: $it",
style = MaterialTheme.typography.bodySmall,
color = upiColor
)
} }
payment.payerName?.takeIf { it.isNotBlank() }?.let { payment.payerName?.takeIf { it.isNotBlank() }?.let {
Text(text = "Payer: $it", style = MaterialTheme.typography.bodySmall) Text(
text = "Payer: $it",
style = MaterialTheme.typography.bodySmall,
color = payerColor
)
} }
dateText?.let { dateText?.let {
Text(text = "Received: $it", style = MaterialTheme.typography.bodySmall) Text(text = "Received: $it", style = MaterialTheme.typography.bodySmall)

View File

@@ -87,4 +87,41 @@ class BookingPaymentsViewModel : ViewModel() {
} }
} }
} }
fun deleteCashPayment(propertyId: String, bookingId: String, paymentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.deletePayment(
propertyId = propertyId,
bookingId = bookingId,
paymentId = paymentId
)
if (response.isSuccessful) {
_state.update { current ->
current.copy(
isLoading = false,
payments = current.payments.filterNot { it.id == paymentId },
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
error = "Delete failed: ${response.code()}"
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = e.localizedMessage ?: "Delete failed"
)
}
}
}
}
} }

View File

@@ -32,6 +32,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -59,6 +60,10 @@ fun PayuQrScreen(
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(Unit) {
onDispose { viewModel.reset() }
}
LaunchedEffect(pendingAmount) { LaunchedEffect(pendingAmount) {
viewModel.setInitialAmount(pendingAmount) viewModel.setInitialAmount(pendingAmount)
} }

View File

@@ -20,6 +20,10 @@ class PayuQrViewModel : ViewModel() {
) )
val state: StateFlow<PayuQrState> = _state val state: StateFlow<PayuQrState> = _state
fun reset() {
_state.value = PayuQrState(deviceInfo = buildDeviceInfo())
}
fun onAmountChange(value: String) { fun onAmountChange(value: String) {
val digits = value.filter { it.isDigit() } val digits = value.filter { it.isDigit() }
_state.update { it.copy(amountInput = digits, error = null) } _state.update { it.copy(amountInput = digits, error = null) }

View File

@@ -126,9 +126,7 @@ fun ActiveRoomStaysScreen(
items(state.checkedInBookings) { booking -> items(state.checkedInBookings) { booking ->
CheckedInBookingCard( CheckedInBookingCard(
booking = booking, booking = booking,
onClick = { selectedBooking.value = booking }, onClick = { onOpenBookingDetails(booking) })
onLongClick = { onOpenBookingDetails(booking) }
)
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -152,12 +150,6 @@ fun ActiveRoomStaysScreen(
) { ) {
Text("Manage room stay") Text("Manage room stay")
} }
TextButton(onClick = { selectedBooking.value = null }) {
Text("Balance")
}
TextButton(onClick = { selectedBooking.value = null }) {
Text("Add photos")
}
TextButton(onClick = { selectedBooking.value = null }) { TextButton(onClick = { selectedBooking.value = null }) {
Text("Checkout") Text("Checkout")
} }
@@ -173,14 +165,11 @@ fun ActiveRoomStaysScreen(
@Composable @Composable
private fun CheckedInBookingCard( private fun CheckedInBookingCard(
booking: BookingListItem, booking: BookingListItem,
onClick: () -> Unit, onClick: () -> Unit) {
onLongClick: () -> Unit
) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.combinedClickable( modifier = Modifier.combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick
) )
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {

View File

@@ -55,6 +55,7 @@ import coil.request.ImageRequest
import com.android.trisolarispms.data.api.ApiConstants import com.android.trisolarispms.data.api.ApiConstants
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -74,10 +75,12 @@ fun BookingDetailsTabsScreen(
onEditSignature: (String) -> Unit, onEditSignature: (String) -> Unit,
onOpenPayuQr: (Long?, String?) -> Unit, onOpenPayuQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit, onOpenPayments: () -> Unit,
canManageDocuments: Boolean,
staysViewModel: BookingRoomStaysViewModel = viewModel(), staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel() detailsViewModel: BookingDetailsViewModel = viewModel()
) { ) {
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) val tabCount = if (canManageDocuments) 3 else 2
val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabCount })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val staysState by staysViewModel.state.collectAsState() val staysState by staysViewModel.state.collectAsState()
val detailsState by detailsViewModel.state.collectAsState() val detailsState by detailsViewModel.state.collectAsState()
@@ -120,6 +123,15 @@ fun BookingDetailsTabsScreen(
}, },
text = { Text("Room Stays") } text = { Text("Room Stays") }
) )
if (canManageDocuments) {
Tab(
selected = pagerState.currentPage == 2,
onClick = {
scope.launch { pagerState.animateScrollToPage(2) }
},
text = { Text("Manage Documents") }
)
}
} }
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
@@ -138,6 +150,24 @@ fun BookingDetailsTabsScreen(
onOpenPayments = onOpenPayments onOpenPayments = onOpenPayments
) )
1 -> BookingRoomStaysTabContent(staysState, staysViewModel) 1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
2 -> if (canManageDocuments) {
val resolvedGuestId = detailsState.details?.guestId ?: guestId
if (!resolvedGuestId.isNullOrBlank()) {
GuestDocumentsTab(
propertyId = propertyId,
guestId = resolvedGuestId,
bookingId = bookingId,
canManageDocuments = canManageDocuments
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "Guest not linked yet")
}
}
}
} }
} }
} }