Compare commits

..
7 Commits
Author SHA1 Message Date
darkorion 4cebf300aa feat: «глазик» в поле пароля при входе и смена своего пароля (сайт — своя карточка, приложение — «Настройки»); Android 1.11.0 2026-10-16 16:41:52 +03:00
darkorion 4f2aed1770 feat: крупная кнопка закрытия отказа и неисправности; полная форма закрытия в приложении Android (как на сайте); «инцидент» → «отказ»; Android 1.10.0, руководство 2026-10-15 19:58:40 +03:00
darkorion 849645076c chore: убрана ссылка на удалённый отчёт 2026-10-13 14:55:15 +03:00
darkorion 4e8ba272f1 fix: пустые страницы в руководстве — подписи к высокому рисунку переносятся на новую страницу, высокие снимки уменьшаются 2026-10-13 14:45:51 +03:00
darkorion 0f239d5015 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00
darkorion 3a6e1f444c docs: требования к серверу в README 2026-10-13 09:32:48 +03:00
darkorion 67e411eea8 Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android 2026-10-13 09:09:53 +03:00
23 changed files with 438 additions and 42 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 = 17
versionName = "1.11.0"
}
signingConfigs {
@@ -103,6 +103,12 @@ class Repository(
/** Сообщение о смене режима (показывается один раз). */
val modeChanged = MutableStateFlow<String?>(null)
/** Сменить свой пароль: другие сессии завершаются, это устройство получает новые токены. */
suspend fun changePassword(old: String, new: String) {
val t = client.api().changePassword(ChangePasswordReq(old, new))
prefs.access = t.accessToken; prefs.refresh = t.refreshToken
}
/** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */
suspend fun leaveDemo() {
val t = client.api().demoLeave()
@@ -28,6 +28,9 @@ interface Api {
@POST("auth/login")
suspend fun login(@Body b: LoginReq): TokenRes
@POST("auth/change-password")
suspend fun changePassword(@Body b: ChangePasswordReq): TokenRes
@POST("auth/logout")
suspend fun logout(@Body b: RefreshReq)
@@ -131,7 +134,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>
@@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable
@Serializable
data class LoginReq(val username: String, val password: String, val device: String = "android")
@Serializable
data class ChangePasswordReq(@SerialName("old_password") val oldPassword: String, @SerialName("new_password") val newPassword: String)
@Serializable
data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android")
@@ -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 }) }
}
}
}
}
@@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.checklists.app.data.api.humanMessage
@@ -79,8 +78,7 @@ fun LoginScreen(onDone: () -> Unit) {
OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") },
singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
PasswordField(pass, { pass = it }, "Пароль")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти")
@@ -0,0 +1,88 @@
package ru.checklists.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.checklists.app.data.api.humanMessage
import ru.checklists.app.repo
private const val PASSWORD_MIN = 10 // как на сервере (app/schemas.py)
/** Поле пароля с «глазиком»: показать или скрыть введённое. */
@Composable
fun PasswordField(value: String, onChange: (String) -> Unit, label: String, modifier: Modifier = Modifier.fillMaxWidth(), supporting: String? = null) {
var show by remember { mutableStateOf(false) }
OutlinedTextField(value, onChange, label = { Text(label) }, singleLine = true, modifier = modifier,
visualTransformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = supporting?.let { { Text(it) } },
trailingIcon = {
val hint = if (show) "Скрыть пароль" else "Показать пароль"
IconButton({ show = !show }, Modifier.semantics { contentDescription = hint }) { Text(if (show) "🙈" else "👁") }
})
}
/** Смена своего пароля: текущий, новый и повтор. Другие сессии завершаются, это устройство остаётся в системе. */
@Composable
fun ChangePasswordDialog(onDismiss: () -> Unit, onDone: () -> Unit) {
val repo = LocalContext.current.repo
val scope = rememberCoroutineScope()
var old by remember { mutableStateOf("") }
var new by remember { mutableStateOf("") }
var repeat by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun validate(): String? = when {
old.isEmpty() -> "Введите текущий пароль"
new.length < PASSWORD_MIN -> "Новый пароль — не короче $PASSWORD_MIN символов"
new != repeat -> "Новые пароли не совпадают"
new == old -> "Новый пароль совпадает с текущим"
else -> null
}
AlertDialog(onDismissRequest = { if (!busy) onDismiss() }, title = { Text("Смена пароля") }, text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PasswordField(old, { old = it }, "Текущий пароль")
PasswordField(new, { new = it }, "Новый пароль", supporting = "Не короче $PASSWORD_MIN символов")
PasswordField(repeat, { repeat = it }, "Новый пароль ещё раз")
Text("После смены на других устройствах и на сайте нужно будет войти заново.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}, confirmButton = {
TextButton({
error = validate()
if (error == null) {
busy = true
scope.launch {
try { repo.changePassword(old, new); onDone() }
catch (e: Throwable) { error = e.humanMessage() }
finally { busy = false }
}
}
}, enabled = !busy) { Text("Сменить") }
}, dismissButton = { TextButton(onDismiss, enabled = !busy) { Text("Отмена") } })
}
@@ -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
@@ -45,6 +45,8 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
val error by repo.lastError.collectAsState()
var confirmLogout by remember { mutableStateOf(false) }
var unsynced by remember { mutableStateOf(0) }
var changePass by remember { mutableStateOf(false) }
val ctx = LocalContext.current
Scaffold(topBar = {
ScreenBar(title = { Text("Настройки") })
@@ -55,6 +57,7 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") }
if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор")
if (me?.isStorekeeper == true) Text("Администратор склада")
if (me?.demoMode != true) OutlinedButton({ changePass = true }, Modifier.fillMaxWidth()) { Text("🔑 Сменить пароль") }
Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp))
Text(repo.prefs.baseUrl)
@@ -77,6 +80,11 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
}
}
if (changePass) ChangePasswordDialog(onDismiss = { changePass = false }) {
changePass = false
android.widget.Toast.makeText(ctx, "Пароль изменён", android.widget.Toast.LENGTH_LONG).show()
}
if (confirmLogout) AlertDialog(
onDismissRequest = { confirmLogout = false },
title = { Text("Выйти из аккаунта?") },
@@ -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
View File
@@ -45,6 +45,7 @@
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
- Смена своего пароля: своя карточка (имя вверху меню, #/user/<свой id>) → блок «🔑 Пароль» → «Сменить пароль»; в приложении Android — «Настройки» → «Сменить пароль». Нужен текущий пароль; другие устройства выйдут из системы. Забытый пароль сбрасывает администратор или модератор в #/users. При входе значок 👁 показывает введённый пароль.
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
+14 -1
View File
@@ -180,6 +180,19 @@ async function route() {
}
window.addEventListener("hashchange", route);
// поле пароля с «глазиком»: показать или скрыть введённое
function pwEye(input) {
const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁");
btn.onclick = () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "🙈" : "👁";
btn.title = show ? "Скрыть пароль" : "Показать пароль";
btn.setAttribute("aria-label", btn.title);
};
return h("div", { class: "pwwrap" }, input, btn);
}
function loginPage() {
$top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren();
const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" });
@@ -205,7 +218,7 @@ function loginPage() {
}).catch(() => {});
fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).catch(() => {});
$app.replaceChildren(h("div", { class: "card login" }, h("h1", {}, "Вход"), note,
h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), p,
h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), pwEye(p),
h("div", { style: "height:14px" }), h("button", { onclick: go }, "Войти"), h("div", { style: "margin-top:14px" }, apk), docs));
}
+4
View File
@@ -81,3 +81,7 @@ body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0
.fbm{opacity:.7;font-size:13px}
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px}
.fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px}
/* поле пароля с «глазиком» */
.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px}
.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0}
+15 -2
View File
@@ -399,7 +399,7 @@ function renderDrawer() {
h("a", { href: "#", class: route.page === "list" ? "on" : "", onclick: e => { e.preventDefault(); go("")(); } }, h("i", {}, "📋"), "Наряды"),
h("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"),
h("div", { class: "navh" }, "Портал (нужна связь)"),
web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и инциденты"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null,
web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и отказы"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null,
web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"),
store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null,
web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"),
@@ -448,6 +448,19 @@ const badges = o => h("span", { class: "row", style: "gap:6px" }, h("span", { cl
const progress = o => { const t = (o.items || []).length; return t ? [(o.items || []).filter(itemDone).length, t] : null; };
// ----- вход -----
// поле пароля с «глазиком»: показать или скрыть введённое
function pwEye(input) {
const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁");
btn.onclick = () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "🙈" : "👁";
btn.title = show ? "Скрыть пароль" : "Показать пароль";
btn.setAttribute("aria-label", btn.title);
};
return h("div", { class: "pwwrap" }, input, btn);
}
function loginView(my) {
setBar(h("h1", {}, "Чек-листы"));
const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" });
@@ -472,7 +485,7 @@ function loginView(my) {
const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone;
commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"),
sync.error && hasSession() === false && sync.error.startsWith("Сессия") ? h("p", { class: "warn" }, sync.error) : null,
u, p, err, btn, apk,
u, pwEye(p), err, btn, apk,
ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null,
h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта"))));
}
+25 -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();
@@ -808,6 +808,29 @@ async function peoplePage() {
draw();
}
// Смена своего пароля: старый, новый и повтор. Остальные сессии завершаются, эта получает новые токены.
function passwordCard() {
const inp = ac => h("input", { type: "password", autocomplete: ac });
const old = inp("current-password"), nw = inp("new-password"), rep = inp("new-password");
const body = h("div", { style: "display:none;margin-top:8px" },
h("div", { class: "grid" }, field("Текущий пароль", pwEye(old)), field("Новый пароль", pwEye(nw), "Не короче 10 символов"), field("Новый пароль ещё раз", pwEye(rep))),
h("div", { class: "row", style: "margin-top:10px" },
h("button", { onclick: guard(async () => {
if (!old.value) throw new Error("Введите текущий пароль");
if (nw.value.length < 10) throw new Error("Новый пароль — не короче 10 символов");
if (nw.value !== rep.value) throw new Error("Новые пароли не совпадают");
if (nw.value === old.value) throw new Error("Новый пароль совпадает с текущим");
const t = await api("POST", "/auth/change-password", { old_password: old.value, new_password: nw.value });
token = t.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token);
old.value = nw.value = rep.value = ""; body.style.display = "none";
toast("Пароль изменён. На других устройствах войдите заново");
}) }, "Сменить пароль"),
h("button", { class: "sec", onclick: () => { body.style.display = "none"; } }, "Отмена")));
return h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, "🔑 Пароль"),
h("button", { class: "sec sm", onclick: () => { body.style.display = body.style.display === "none" ? "" : "none"; if (body.style.display === "") old.focus(); } }, "Сменить пароль")),
h("div", { class: "small muted", style: "margin-top:4px" }, "После смены пароля вход на других устройствах и в приложении нужно будет выполнить заново."), body);
}
async function userCardPage(id) {
const c = await api("GET", `/users/${id}/card`);
const u = c.user, reload = () => userCardPage(id);
@@ -824,6 +847,7 @@ async function userCardPage(id) {
u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null,
u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null,
u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)),
+id === me.id ? passwordCard() : null,
h("div", { class: "cols" },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"),
c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null,
+8
View File
@@ -302,3 +302,11 @@ 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)}
/* поле пароля с «глазиком» */
.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px}
.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0}
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.11.0.apk` — подписанное release-приложение для Android (версия 1.11.0, versionCode 17; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk`
и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее).
SHA-256: `d5764868c000bdd081a5c71ed215c516738f266b8e8a2eea0bfd0836b1209aaf`
SHA-256: `49ed6928967ab665c7171ce64ed1491ab3d1245d03928c74fe2437630ab0db8e`
Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто
положите новый файл в том `appdata` по пути `apk/` (без передеплоя).
+17 -6
View File
@@ -386,7 +386,8 @@ def build():
"он зададит новый.")
pdf.h2("Вход на сайте")
pdf.wide("d_login", "Страница входа", width=120)
pdf.p("Введите логин и пароль и нажмите «Войти». Если администратор загрузил приложение для Android, под кнопкой будет "
pdf.p("Введите логин и пароль и нажмите «Войти». Значок-глаз в поле пароля показывает введённое (ещё раз — скрывает): удобно "
"проверить пароль перед входом. Если администратор загрузил приложение для Android, под кнопкой будет "
"ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток "
"подряд вход на 5 минут блокируется — подождите и повторите.")
@@ -666,11 +667,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 +685,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("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё "
"работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). "
@@ -839,6 +844,11 @@ def build():
pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); "
"модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому "
"сотруднику, складу и инженерам.", "info")
pdf.h2("Смена своего пароля")
pdf.p("Сменить свой пароль можно самому, без администратора. На сайте: нажмите своё имя вверху бокового меню → блок **«Пароль»** → "
"«Сменить пароль»: текущий пароль, новый (не короче 10 символов) и ещё раз новый. В приложении для Android: «Настройки» → "
"**«Сменить пароль»**. На этом устройстве вы останетесь в системе, а на остальных (и в приложении на телефоне) нужно будет "
"войти заново с новым паролем. Забытый пароль сбрасывает администратор или модератор."),
# ================= 6 =================
pdf.h1("13. Администратор и модератор")
@@ -1027,7 +1037,8 @@ def build():
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
"со статусом «Скоро» и «Требуется» показаны вверху списка."),
("Я забыл пароль.",
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."),
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль. Затем смените его на свой: "
"своя карточка → «Пароль» (в приложении — «Настройки» → «Сменить пароль»)."),
("Время в нарядах отличается от времени на телефоне.",
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
"другой часовой пояс."),