feat: крупная кнопка закрытия отказа и неисправности; полная форма закрытия в приложении Android (как на сайте); «инцидент» → «отказ»; Android 1.10.0, руководство

This commit is contained in:
darkorion
2026-10-15 19:58:40 +03:00
parent 849645076c
commit 4f2aed1770
14 changed files with 258 additions and 34 deletions
+2 -2
View File
@@ -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 {
@@ -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<StockDto>
@@ -136,11 +136,31 @@ data class IncidentDto(
@SerialName("replacement_note") val replacementNote: String = "",
@SerialName("close_note") val closeNote: String = "",
val units: List<IncidentUnitDto> = 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<Long> = emptyList(),
)
@Serializable
data class StockDto(
@SerialName("item_name") val itemName: String,
@@ -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<UnitBriefDto>, 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<Long?>(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<Long>() }
var error by remember { mutableStateOf<String?>(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<Pair<Long?, String>>(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 <T> Pick(label: String, options: List<Pair<T, String>>, 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 }) }
}
}
}
}
@@ -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<UnitBriefDto>, kind: String, onDismiss: (
var replaced by remember { mutableStateOf(false) }
var note by remember { mutableStateOf("") }
val picked = remember { mutableStateMapOf<Long, Boolean>() } // узел -> заменён
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
@@ -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("Краны")
@@ -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<IncidentDto>, maps: Remote<Map<String, MapDto
if (scale > 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)
+1 -1
View File
@@ -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();
+4
View File
@@ -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)}
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.
+2 -2
View File
@@ -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/` (без передеплоя).
+8 -4
View File
@@ -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("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё "
"работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). "