diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e221ac1..74b7179 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ru.checklists.app" minSdk = 26 targetSdk = 34 - versionCode = 14 - versionName = "1.9.0" + versionCode = 16 + versionName = "1.10.0" } signingConfigs { diff --git a/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt b/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt index 01d66d6..166f0c5 100644 --- a/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt +++ b/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt @@ -131,7 +131,7 @@ interface Api { suspend fun createIncident(@Body b: IncidentReq): IncidentDto @POST("incidents/{id}/close") - suspend fun closeIncident(@Path("id") id: Long, @Body b: CloseReq): IncidentDto + suspend fun closeIncident(@Path("id") id: Long, @Body b: FailureCloseReq): IncidentDto @GET("me/inventory") suspend fun myInventory(): List diff --git a/android/app/src/main/java/ru/checklists/app/data/api/PortalModels.kt b/android/app/src/main/java/ru/checklists/app/data/api/PortalModels.kt index 004975e..be35967 100644 --- a/android/app/src/main/java/ru/checklists/app/data/api/PortalModels.kt +++ b/android/app/src/main/java/ru/checklists/app/data/api/PortalModels.kt @@ -136,11 +136,31 @@ data class IncidentDto( @SerialName("replacement_note") val replacementNote: String = "", @SerialName("close_note") val closeNote: String = "", val units: List = emptyList(), + @SerialName("escalated_at") val escalatedAt: String? = null, + @SerialName("hours_at_open") val hoursAtOpen: Double? = null, ) @Serializable data class CloseReq(val note: String = "") +/** Закрытие отказа или неисправности с данными для анализа надёжности (как форма на сайте). */ +@Serializable +data class FailureCloseReq( + val note: String = "", + val action: String? = null, // repair | replace | adjust | restart + val nature: String? = null, // sudden | gradual + val cause: String? = null, + val precursor: String? = null, + @SerialName("repair_started_at") val repairStartedAt: String? = null, + @SerialName("restored_at") val restoredAt: String? = null, + @SerialName("parts_wait_hours") val partsWaitHours: Double? = null, + @SerialName("hours_at_failure") val hoursAtFailure: Double? = null, + @SerialName("meter_at_restore") val meterAtRestore: Double? = null, + @SerialName("cause_unit_id") val causeUnitId: Long? = null, + @SerialName("cause_unit_other") val causeUnitOther: String = "", + @SerialName("replaced_units") val replacedUnits: List = emptyList(), +) + @Serializable data class StockDto( @SerialName("item_name") val itemName: String, diff --git a/android/app/src/main/java/ru/checklists/app/ui/FailureCloseDialog.kt b/android/app/src/main/java/ru/checklists/app/ui/FailureCloseDialog.kt new file mode 100644 index 0000000..1136db2 --- /dev/null +++ b/android/app/src/main/java/ru/checklists/app/ui/FailureCloseDialog.kt @@ -0,0 +1,192 @@ +package ru.checklists.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ru.checklists.app.data.api.FailureCloseReq +import ru.checklists.app.data.api.IncidentDto +import ru.checklists.app.data.api.UnitBriefDto +import ru.checklists.app.util.parseMillis +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +// Справочники записи об отказе — как на сайте (app/static/portal.js, FAIL_DICT) и на сервере (app/reliability.py) +private val ACTIONS = listOf("repair" to "Ремонт", "replace" to "Замена узла", "adjust" to "Регулировка или наладка", "restart" to "Перезапуск без ремонта (сбой)") +private val NATURES = listOf("sudden" to "Внезапный", "gradual" to "Постепенный") +private val CAUSES = listOf("wear" to "Износ и старение", "operation" to "Нарушение условий эксплуатации, ошибка персонала", + "external" to "Внешние воздействия (температура, влага, пыль, вибрация)", "power" to "Электропитание или смежные системы", + "defect" to "Дефект изготовления, монтажа или прошлого ремонта", "secondary" to "Вследствие отказа другого узла", "unknown" to "Не установлена") +private val PRECURSORS = listOf("inspection" to "Замечено на обходе или в чек-листе", "fault_report" to "Было сообщение о неисправности", "none" to "Признаков не было") + +private val inFmt = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm") +private fun toInput(iso: String?): String = parseMillis(iso)?.let { inFmt.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } ?: "" +private fun nowInput(): String = inFmt.format(LocalDateTime.now()) + +/** «15.10.2026 13:20» → ISO с часовым поясом; пусто → null; неверный формат → исключение с понятным текстом. */ +private fun toIso(label: String, v: String): String? { + if (v.isBlank()) return null + val t = try { LocalDateTime.parse(v.trim(), inFmt) } catch (e: Exception) { error("$label: укажите в виде 15.10.2026 13:20") } + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(t.atZone(ZoneId.systemDefault())) +} + +private fun num(label: String, v: String): Double? { + if (v.isBlank()) return null + return v.trim().replace(',', '.').toDoubleOrNull()?.takeIf { it >= 0 } ?: error("$label: введите число") +} + +private fun fmtNum(v: Double) = if (v == v.toLong().toDouble()) v.toLong().toString() else "%.1f".format(v).replace(',', '.') + +/** + * Форма закрытия отказа или неисправности — те же данные, что на сайте, чтобы отказы, закрытые с телефона, + * полностью попадали в анализ надёжности. Всё, что можно, подставлено заранее; для отказа обязательны + * «что сделано» и «характер». + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FailureCloseDialog(i: IncidentDto, units: List, onDismiss: () -> Unit, onSave: (FailureCloseReq) -> Unit) { + val fault = i.kind == "fault" + val start = i.escalatedAt ?: i.occurredAt + var action by remember { mutableStateOf("") } + var nature by remember { mutableStateOf("") } + var note by remember { mutableStateOf("") } + var repairStarted by remember { mutableStateOf(if (fault) "" else toInput(start)) } + var restored by remember { mutableStateOf(if (fault) "" else nowInput()) } + var partsWait by remember { mutableStateOf("") } + var hoursAtFailure by remember { mutableStateOf(i.hoursAtOpen?.let { fmtNum(it) } ?: "") } + var meter by remember { mutableStateOf("") } + var causeUnit by remember { mutableStateOf(null) } // -1 — «другое» + var causeOther by remember { mutableStateOf("") } + var cause by remember { mutableStateOf(if (fault) "" else "unknown") } + var precursor by remember { mutableStateOf(if (i.escalatedAt != null) "fault_report" else "") } + val replaced = remember { mutableStateListOf() } + var error by remember { mutableStateOf(null) } + val need = !fault + + AlertDialog(onDismissRequest = onDismiss, + title = { Text(if (fault) "Неисправность устранена" else "Закрытие отказа") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Что сделано" + if (need) " *" else "", fontWeight = FontWeight.SemiBold) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ACTIONS.forEach { (k, t) -> FilterChip(selected = action == k, onClick = { action = if (action == k) "" else k }, label = { Text(t) }) } + } + if (!fault) { + Text("Характер отказа *", fontWeight = FontWeight.SemiBold) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + NATURES.forEach { (k, t) -> FilterChip(selected = nature == k, onClick = { nature = if (nature == k) "" else k }, label = { Text(t) }) } + } + } + OutlinedTextField(note, { note = it }, label = { Text("Описание устранения") }, placeholder = { Text("Что сделали, что заменили, рекомендации") }, + minLines = 2, modifier = Modifier.fillMaxWidth()) + HorizontalDivider() + Text(if (fault) "Подробнее (необязательно)" else "Время, наработка и причина — подставлено, поправьте при необходимости", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) + DateField("Начало работ" + if (need) " *" else "", repairStarted) { repairStarted = it } + DateField("Ввод в эксплуатацию" + if (need) " *" else "", restored) { restored = it } + NumField("Ожидание запчастей, ч", partsWait) { partsWait = it } + NumField("Наработка на момент отказа, м/ч", hoursAtFailure) { hoursAtFailure = it } + NumField("Показание счётчика при вводе, м/ч", meter, "если снимали") { meter = it } + Pick("Узел-причина", listOf>(null to "— не указан —") + units.map { it.id to it.name } + (-1L to "Другое…"), causeUnit) { causeUnit = it } + if (causeUnit == -1L) OutlinedTextField(causeOther, { causeOther = it }, label = { Text("Какой узел или деталь") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Pick("Причина", listOf("" to "— выберите —") + CAUSES, cause) { cause = it } + Pick("Были ли признаки заранее", listOf("" to "— не указано —") + PRECURSORS, precursor) { precursor = it } + if (units.isNotEmpty()) { + Text("Заменённые при ремонте узлы", fontWeight = FontWeight.SemiBold) + units.forEach { u -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(u.id in replaced, { on -> if (on) replaced.add(u.id) else replaced.remove(u.id) }) + Column { + Text(u.name) + Text("заменён — ресурс узла сбросится", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) + } + } + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton({ + try { + if (need && action.isBlank()) error("Выберите, что сделано") + if (need && nature.isBlank()) error("Укажите характер отказа: внезапный или постепенный") + if (causeUnit == -1L && causeOther.isBlank()) error("Укажите, какой узел — причина") + if (need && (repairStarted.isBlank() || restored.isBlank())) error("Укажите начало работ и ввод в эксплуатацию") + val req = FailureCloseReq( + note = note.trim(), action = action.ifBlank { null }, nature = nature.ifBlank { null }, + cause = cause.ifBlank { null }, precursor = precursor.ifBlank { null }, + repairStartedAt = toIso("Начало работ", repairStarted), restoredAt = toIso("Ввод в эксплуатацию", restored), + partsWaitHours = num("Ожидание запчастей", partsWait), hoursAtFailure = num("Наработка на момент отказа", hoursAtFailure), + meterAtRestore = num("Показание счётчика", meter), + causeUnitId = causeUnit?.takeIf { it > 0 }, causeUnitOther = if (causeUnit == -1L) causeOther.trim() else "", + replacedUnits = replaced.toList()) + error = null + onSave(req) + } catch (e: IllegalStateException) { error = e.message } + }) { Text("Закрыть", fontWeight = FontWeight.Bold) } + }, + dismissButton = { TextButton(onDismiss) { Text("Отмена") } }) +} + +@Composable +private fun DateField(label: String, value: String, onChange: (String) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedTextField(value, onChange, label = { Text(label) }, placeholder = { Text("дд.мм.гггг чч:мм") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedButton({ onChange(nowInput()) }) { Text("Сейчас") } + } +} + +@Composable +private fun NumField(label: String, value: String, hint: String = "", onChange: (String) -> Unit) { + OutlinedTextField(value, onChange, label = { Text(label) }, placeholder = { if (hint.isNotBlank()) Text(hint) }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth()) +} + +@Composable +private fun Pick(label: String, options: List>, value: T, onChange: (T) -> Unit) { + var open by remember { mutableStateOf(false) } + Column { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) + Box { + OutlinedButton({ open = true }, Modifier.fillMaxWidth()) { + Text(options.firstOrNull { it.first == value }?.second ?: "—", modifier = Modifier.weight(1f).padding(end = 4.dp)) + Text("▾") + } + DropdownMenu(open, { open = false }) { + options.forEach { (k, t) -> DropdownMenuItem(text = { Text(t) }, onClick = { onChange(k); open = false }) } + } + } + } +} diff --git a/android/app/src/main/java/ru/checklists/app/ui/PortalScreens.kt b/android/app/src/main/java/ru/checklists/app/ui/PortalScreens.kt index b5be6c0..28bbbf9 100644 --- a/android/app/src/main/java/ru/checklists/app/ui/PortalScreens.kt +++ b/android/app/src/main/java/ru/checklists/app/ui/PortalScreens.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -51,6 +52,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.core.content.FileProvider import coil.compose.AsyncImage @@ -416,11 +418,11 @@ fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit = Text(eq.name + if (eq.inventoryNo.isNotBlank()) " (№ ${eq.inventoryNo})" else "", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) Badge(EQ_RU[h.state] ?: h.state, eqColor(h.state)) } - if (h.openIncidents > 0) Text("Открытых инцидентов: ${h.openIncidents}", color = eqColor("alarm")) + if (h.openIncidents > 0) Text("Открытых отказов: ${h.openIncidents}", color = eqColor("alarm")) if (h.openFaults > 0) Text("Открытых неисправностей: ${h.openFaults}", color = eqColor("fault")) if (h.due + h.soon > 0) Text("Узлы: требуется ТО — ${h.due}, скоро — ${h.soon}", color = eqColor("soon")) Button({ form = "incident" }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { - Text("❗ Зафиксировать инцидент") + Text("❗ Зафиксировать отказ") } Button({ form = "fault" }, colors = ButtonDefaults.buttonColors(containerColor = eqColor("fault")), modifier = Modifier.fillMaxWidth()) { Text("⚠ Сообщить о неисправности") @@ -429,8 +431,8 @@ fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit = } } } - item { SectionTitle("Инциденты и неисправности (${incs.size})") } - if (incs.isEmpty()) item { Text("Инцидентов и неисправностей не было", color = MaterialTheme.colorScheme.secondary) } + item { SectionTitle("Отказы и неисправности (${incs.size})") } + if (incs.isEmpty()) item { Text("Отказов и неисправностей не было", color = MaterialTheme.colorScheme.secondary) } items(incs, key = { it.id }) { i -> val fault = i.kind == "fault" ElevatedCard(Modifier.fillMaxWidth()) { @@ -446,8 +448,11 @@ fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit = if (i.replaced) Text("🔧 Была замена" + if (i.replacementNote.isNotBlank()) ": ${i.replacementNote}" else "", style = MaterialTheme.typography.bodySmall) if (i.closeNote.isNotBlank()) Text("Закрыт: ${i.closeNote}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) val canClose = i.status == "open" && (i.authorId == me?.id || me?.isEngineer == true || me?.isManager == true) - if (canClose) OutlinedButton({ closing = i }) { Text(if (fault) "✔ Неисправность устранена" else "✔ Закрыть инцидент") } - if (fault && i.status == "open") TextButton({ escalating = i }) { Text("❗ Перевести в инцидент (оборудование остановлено)", color = eqColor("alarm")) } + if (canClose) Button({ closing = i }, Modifier.fillMaxWidth().padding(top = 6.dp).heightIn(min = 52.dp), + colors = ButtonDefaults.buttonColors(containerColor = eqColor("ok"), contentColor = Color.White)) { + Text(if (fault) "✔ Неисправность устранена" else "✔ Закрыть отказ", fontWeight = FontWeight.Bold, fontSize = 17.sp) + } + if (fault && i.status == "open") TextButton({ escalating = i }) { Text("❗ Перевести в отказ (оборудование остановлено)", color = eqColor("alarm")) } } } } @@ -457,33 +462,32 @@ fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit = scope.launch { try { client.api().createIncident(req.copy(equipmentId = id)); form = null - toast(ctx, if (kind == "fault") "Неисправность зарегистрирована" else "Инцидент зафиксирован"); r.reload() + toast(ctx, if (kind == "fault") "Неисправность зарегистрирована" else "Отказ зафиксирован"); r.reload() } catch (e: Throwable) { toast(ctx, e.humanMessage()) } } } } } closing?.let { i -> - var note by remember { mutableStateOf("") } - AlertDialog(onDismissRequest = { closing = null }, title = { Text(if (i.kind == "fault") "Неисправность устранена?" else "Закрыть инцидент?") }, text = { - OutlinedTextField(note, { note = it }, label = { Text("Как устранено") }, minLines = 2, modifier = Modifier.fillMaxWidth()) - }, confirmButton = { TextButton({ + FailureCloseDialog(i, r.data?.first?.second.orEmpty().filter { it.isActive }, onDismiss = { closing = null }) { req -> scope.launch { - try { client.api().closeIncident(i.id, CloseReq(note.trim())); closing = null; r.reload() } - catch (e: Throwable) { toast(ctx, e.humanMessage()) } + try { + client.api().closeIncident(i.id, req); closing = null + toast(ctx, if (i.kind == "fault") "Неисправность закрыта" else "Отказ закрыт"); r.reload() + } catch (e: Throwable) { toast(ctx, e.humanMessage()) } } - }) { Text("Закрыть") } }, dismissButton = { TextButton({ closing = null }) { Text("Отмена") } }) + } } escalating?.let { i -> var note by remember { mutableStateOf("") } - AlertDialog(onDismissRequest = { escalating = null }, title = { Text("Перевести в инцидент?") }, text = { + AlertDialog(onDismissRequest = { escalating = null }, title = { Text("Перевести в отказ?") }, text = { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Оборудование остановлено — машина получит состояние «Авария».", style = MaterialTheme.typography.bodySmall) OutlinedTextField(note, { note = it }, label = { Text("Что случилось") }, minLines = 2, modifier = Modifier.fillMaxWidth()) } }, confirmButton = { TextButton({ scope.launch { - try { client.api().escalateIncident(i.id, CloseReq(note.trim())); escalating = null; toast(ctx, "Переведено в инцидент"); r.reload() } + try { client.api().escalateIncident(i.id, CloseReq(note.trim())); escalating = null; toast(ctx, "Переведено в отказ"); r.reload() } catch (e: Throwable) { toast(ctx, e.humanMessage()) } } }) { Text("Перевести") } }, dismissButton = { TextButton({ escalating = null }) { Text("Отмена") } }) @@ -498,9 +502,9 @@ private fun IncidentDialog(units: List, kind: String, onDismiss: ( var replaced by remember { mutableStateOf(false) } var note by remember { mutableStateOf("") } val picked = remember { mutableStateMapOf() } // узел -> заменён - AlertDialog(onDismissRequest = onDismiss, title = { Text(if (fault) "Сообщение о неисправности" else "Инцидент") }, text = { + AlertDialog(onDismissRequest = onDismiss, title = { Text(if (fault) "Сообщение о неисправности" else "Отказ") }, text = { LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { - if (fault) item { Text("Оборудование работает, но замечена проблема. Если станет хуже — переведите сообщение в инцидент.", + if (fault) item { Text("Оборудование работает, но замечена проблема. Если станет хуже — переведите сообщение в отказ.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) } item { OutlinedTextField(title, { title = it }, label = { Text(if (fault) "Что замечено (кратко)" else "Что произошло (кратко)") }, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(desc, { desc = it }, label = { Text("Подробности") }, minLines = 2, modifier = Modifier.fillMaxWidth()) } @@ -610,7 +614,7 @@ fun RequestsScreen() { } // =============================== История машины =============================== -private val HIST = linkedMapOf("incident" to "❗ Инциденты", "service" to "🔧 Обслуживание", "meter" to "⏱ Наработка", "order" to "📋 Наряды", "task" to "🗓 Задания") +private val HIST = linkedMapOf("incident" to "❗ Отказы", "service" to "🔧 Обслуживание", "meter" to "⏱ Наработка", "order" to "📋 Наряды", "task" to "🗓 Задания") @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/android/app/src/main/java/ru/checklists/app/ui/Shell.kt b/android/app/src/main/java/ru/checklists/app/ui/Shell.kt index 6f70108..6674b6f 100644 --- a/android/app/src/main/java/ru/checklists/app/ui/Shell.kt +++ b/android/app/src/main/java/ru/checklists/app/ui/Shell.kt @@ -100,7 +100,7 @@ fun AppDrawer(current: String?, go: (String) -> Unit) { item("orders", "📋", "Наряды") item("tasks", "🗓", "Задачи и поручения") group("Оборудование") - item("equipment", "❗", "Оборудование и инциденты") + item("equipment", "❗", "Оборудование и отказы") if (eng) item("state", "🔧", "Состояние узлов") if (me?.craneMap == true) { group("Краны") diff --git a/android/app/src/main/java/ru/checklists/app/ui/WorkScreen.kt b/android/app/src/main/java/ru/checklists/app/ui/WorkScreen.kt index a2ecf60..bb46bce 100644 --- a/android/app/src/main/java/ru/checklists/app/ui/WorkScreen.kt +++ b/android/app/src/main/java/ru/checklists/app/ui/WorkScreen.kt @@ -112,7 +112,7 @@ fun WorkScreen(go: (String) -> Unit) { } Scaffold(topBar = { - ScreenBar(title = { Column { Text("Задачи"); Text("Наряды, поручения, инциденты, неисправности", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) } }, + ScreenBar(title = { Column { Text("Задачи"); Text("Наряды, поручения, отказы, неисправности", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) } }, actions = { TextButton({ repo.syncSoon(); scope.launch { tasks.reload(); counts.reload() }; tick++ }, enabled = !syncing) { Text(if (syncing) "…" else "⟳") } }) }) { pad -> LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -191,7 +191,7 @@ private fun IncQuad(kind: String, demo: Boolean, tick: Int, go: (String) -> Unit buildMap { put("plant", plant); if (plant.craneMap) put("crane", api.api().map("crane")) } } } - Quad(if (fault) "⚠ Неисправности" else "❗ Инциденты", { go("equipment") }, + Quad(if (fault) "⚠ Неисправности" else "❗ Отказы", { go("equipment") }, listOf("open" to "Открытые", "mine" to "Мои", "all" to "Все", "map" to if (onMap) "☰ Список" else "🗺 На карте") .map { Triple(it.first, it.second, if (it.first == "map") onMap else f == it.first) }, { if (it == "map") onMap = !onMap else f = it }) { val l = r.data.orEmpty() @@ -201,7 +201,7 @@ private fun IncQuad(kind: String, demo: Boolean, tick: Int, go: (String) -> Unit } if (r.error != null) Empty("⚠ ${r.error}") else if (r.data == null) Empty("Загрузка…") - else if (l.isEmpty()) Empty(if (f == "open") (if (fault) "Открытых неисправностей нет" else "Открытых инцидентов нет") else (if (fault) "Сообщений нет" else "Инцидентов нет")) + else if (l.isEmpty()) Empty(if (f == "open") (if (fault) "Открытых неисправностей нет" else "Открытых отказов нет") else (if (fault) "Сообщений нет" else "Отказов нет")) l.take(SHOW).forEach { i -> IncidentLine(i) { i.equipmentId?.let { go("equipment/$it") } } } More(l.size) { go("equipment") } } @@ -289,7 +289,7 @@ private fun IncidentMap(list: List, maps: Remote 1f) Text("Масштаб ${(scale * 100).toInt()}%: двигайте карту пальцем, «⤢» — весь план", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) } - if (hit.isEmpty()) Empty(if (kind == "fault") "На этой карте нет машин с неисправностями по фильтру" else "На этой карте нет машин с инцидентами по фильтру") + if (hit.isEmpty()) Empty(if (kind == "fault") "На этой карте нет машин с неисправностями по фильтру" else "На этой карте нет машин с отказами по фильтру") val unplaced = hit.filter { it.x == null } if (unplaced.isNotEmpty()) Text("Не размещены на карте: " + unplaced.joinToString { it.name }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) diff --git a/app/static/portal.js b/app/static/portal.js index a806754..a7b7ab3 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -320,7 +320,7 @@ function incidentCard(i, onchange) { i.status === "closed" && !f && isEng() ? h("div", { class: "row", style: "margin-top:6px" }, h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "edit")) }, "✎ Данные отказа")) : null, i.status === "open" ? h("div", { class: "row", style: "margin-top:8px" }, - canClose ? h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "close")) }, f ? "✔ Неисправность устранена" : "✔ Закрыть отказ") : null, + canClose ? h("button", { class: "closebtn", onclick: guard(() => failureForm(i, fbox, onchange, "close")) }, f ? "✔ Неисправность устранена" : "✔ Закрыть отказ") : null, f ? h("button", { class: "bad sm", title: "Неисправность привела к остановке оборудования", onclick: guard(async () => { const note = prompt("Оборудование остановлено? Перевести сообщение в отказ (машина получит состояние «Отказ»). Что случилось:", ""); if (note === null) return; await api("POST", `/incidents/${i.id}/escalate`, { note }); toast("Переведено в отказ"); onchange(); diff --git a/app/static/style.css b/app/static/style.css index 6fb2d5c..d537439 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -302,3 +302,7 @@ button.fault{background:var(--fault);border-color:var(--fault);color:#fff} .gauge{text-align:center}.gauge h2{margin:0 0 6px;font-size:16px} .donut{width:150px;height:150px;display:block;margin:4px auto 8px}.donut circle{transition:stroke-dasharray .6s} .donut-v{font-size:24px;font-weight:700;fill:var(--text)} + +/* кнопка закрытия отказа и неисправности: крупная и заметная */ +button.closebtn{background:#16a34a;border:1px solid #16a34a;color:#fff;font-size:16px;font-weight:700;padding:10px 20px;border-radius:10px} +button.closebtn:hover{filter:brightness(1.08)} diff --git a/docs/guide/img/a_failure_close.png b/docs/guide/img/a_failure_close.png new file mode 100644 index 0000000..aa76d50 Binary files /dev/null and b/docs/guide/img/a_failure_close.png differ diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index 831cb35..95f3731 100644 Binary files a/docs/user-guide.pdf and b/docs/user-guide.pdf differ diff --git a/release/README.md b/release/README.md index 69d0316..91a8228 100644 --- a/release/README.md +++ b/release/README.md @@ -1,9 +1,9 @@ # Готовый APK -`checklists-1.9.0.apk` — подписанное release-приложение для Android (версия 1.9.0, versionCode 14; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk` +`checklists-1.10.0.apk` — подписанное release-приложение для Android (версия 1.10.0, versionCode 16; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk` и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее). -SHA-256: `d5764868c000bdd081a5c71ed215c516738f266b8e8a2eea0bfd0836b1209aaf` +SHA-256: `b2c8d8152fa5c9b19eab34c9341ed574fb24d2c0e64294e8c4cc56644fbada14` Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто положите новый файл в том `appdata` по пути `apk/` (без передеплоя). diff --git a/release/checklists-1.9.0.apk b/release/checklists-1.10.0.apk similarity index 71% rename from release/checklists-1.9.0.apk rename to release/checklists-1.10.0.apk index f35e778..30fefc7 100644 Binary files a/release/checklists-1.9.0.apk and b/release/checklists-1.10.0.apk differ diff --git a/tools/guide/build_pdf.py b/tools/guide/build_pdf.py index 83c07ee..c477759 100644 --- a/tools/guide/build_pdf.py +++ b/tools/guide/build_pdf.py @@ -666,11 +666,11 @@ def build(): "Отметка «заменён» у узла **сбрасывает его ресурс**, как ручной сброс: остаток считается заново. В истории узла появится " "запись «замена при отказе».", "Пока отказ открыт, машина в состоянии **«Авария»**; инженерам и администраторам приходит уведомление.", - "Когда неисправность устранена, автор или инженер нажимает **«Закрыть отказ»** и пишет, как устранено.", + "Когда машину починили, автор или инженер нажимает крупную зелёную кнопку **«✔ Закрыть отказ»** и заполняет форму закрытия (ниже).", "Все отказы — раздел **«Отказы»** (открытые и вся история).", ]) pdf.wide("d_incident", "Фиксация отказа") - pdf.phones2("a_equipment", "Машина в приложении Android", "a_incident", "Отказ в приложении (там он пока называется «Инцидент»)") + pdf.phones2("a_equipment", "Машина в приложении Android", "a_incident", "Отказ в приложении") pdf.h2("Закрытие отказа: данные для анализа надёжности") pdf.p("Когда машину починили и запустили, автор отказа или инженер нажимает **«✔ Закрыть отказ»**. Открывается короткая форма: " "почти всё в ней уже подставлено, поэтому дежурному обычно достаточно трёх действий.") @@ -684,8 +684,12 @@ def build(): "Инженер может позже **уточнить данные** закрытого отказа кнопкой «✎ Данные отказа».", ]) pdf.wide("d_failure_close", "Закрытие отказа") - pdf.note("Чем точнее заполнены причина и время, тем полезнее раздел «Анализ надёжности». Если отказ закрыли из приложения для Android, " - "сервер заполнит время и наработку сам, а инженер может дополнить причину на сайте.", "info") + pdf.p("В **приложении для Android** форма та же: что сделано, характер отказа, описание, начало работ и ввод в эксплуатацию " + "(кнопка «Сейчас» подставляет текущее время), ожидание запчастей, наработка, показание счётчика, узел-причина, причина, признаки " + "заранее и заменённые узлы. Отказы, закрытые с телефона, попадают в анализ надёжности полностью.") + pdf.phone("a_failure_close", "Закрытие отказа в приложении Android", width=70) + pdf.note("Чем точнее заполнены причина и время, тем полезнее раздел «Анализ надёжности». Инженер может уточнить данные закрытого " + "отказа на сайте кнопкой «✎ Данные отказа».", "info") pdf.h2("Сообщение о неисправности") pdf.p("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё " "работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). "