Compare commits

...
2 Commits
23 changed files with 438 additions and 42 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "ru.checklists.app" applicationId = "ru.checklists.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 14 versionCode = 17
versionName = "1.9.0" versionName = "1.11.0"
} }
signingConfigs { signingConfigs {
@@ -103,6 +103,12 @@ class Repository(
/** Сообщение о смене режима (показывается один раз). */ /** Сообщение о смене режима (показывается один раз). */
val modeChanged = MutableStateFlow<String?>(null) 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() { suspend fun leaveDemo() {
val t = client.api().demoLeave() val t = client.api().demoLeave()
@@ -28,6 +28,9 @@ interface Api {
@POST("auth/login") @POST("auth/login")
suspend fun login(@Body b: LoginReq): TokenRes suspend fun login(@Body b: LoginReq): TokenRes
@POST("auth/change-password")
suspend fun changePassword(@Body b: ChangePasswordReq): TokenRes
@POST("auth/logout") @POST("auth/logout")
suspend fun logout(@Body b: RefreshReq) suspend fun logout(@Body b: RefreshReq)
@@ -131,7 +134,7 @@ interface Api {
suspend fun createIncident(@Body b: IncidentReq): IncidentDto suspend fun createIncident(@Body b: IncidentReq): IncidentDto
@POST("incidents/{id}/close") @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") @GET("me/inventory")
suspend fun myInventory(): List<StockDto> suspend fun myInventory(): List<StockDto>
@@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class LoginReq(val username: String, val password: String, val device: String = "android") 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 @Serializable
data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android") 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("replacement_note") val replacementNote: String = "",
@SerialName("close_note") val closeNote: String = "", @SerialName("close_note") val closeNote: String = "",
val units: List<IncidentUnitDto> = emptyList(), val units: List<IncidentUnitDto> = emptyList(),
@SerialName("escalated_at") val escalatedAt: String? = null,
@SerialName("hours_at_open") val hoursAtOpen: Double? = null,
) )
@Serializable @Serializable
data class CloseReq(val note: String = "") 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 @Serializable
data class StockDto( data class StockDto(
@SerialName("item_name") val itemName: String, @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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.checklists.app.data.api.humanMessage 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") }, OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") },
singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth()) OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(), PasswordField(pass, { pass = it }, "Пароль")
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { 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("Войти") 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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import coil.compose.AsyncImage 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)) 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)) 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.openFaults > 0) Text("Открытых неисправностей: ${h.openFaults}", color = eqColor("fault"))
if (h.due + h.soon > 0) Text("Узлы: требуется ТО — ${h.due}, скоро — ${h.soon}", color = eqColor("soon")) 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()) { 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()) { Button({ form = "fault" }, colors = ButtonDefaults.buttonColors(containerColor = eqColor("fault")), modifier = Modifier.fillMaxWidth()) {
Text("⚠ Сообщить о неисправности") Text("⚠ Сообщить о неисправности")
@@ -429,8 +431,8 @@ fun EquipmentDetailScreen(id: Long, onBack: () -> Unit, onHistory: () -> Unit =
} }
} }
} }
item { SectionTitle("Инциденты и неисправности (${incs.size})") } item { SectionTitle("Отказы и неисправности (${incs.size})") }
if (incs.isEmpty()) item { Text("Инцидентов и неисправностей не было", color = MaterialTheme.colorScheme.secondary) } if (incs.isEmpty()) item { Text("Отказов и неисправностей не было", color = MaterialTheme.colorScheme.secondary) }
items(incs, key = { it.id }) { i -> items(incs, key = { it.id }) { i ->
val fault = i.kind == "fault" val fault = i.kind == "fault"
ElevatedCard(Modifier.fillMaxWidth()) { 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.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) 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) 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 (canClose) Button({ closing = i }, Modifier.fillMaxWidth().padding(top = 6.dp).heightIn(min = 52.dp),
if (fault && i.status == "open") TextButton({ escalating = i }) { Text("❗ Перевести в инцидент (оборудование остановлено)", color = eqColor("alarm")) } 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 { scope.launch {
try { try {
client.api().createIncident(req.copy(equipmentId = id)); form = null 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()) } } catch (e: Throwable) { toast(ctx, e.humanMessage()) }
} }
} }
} }
} }
closing?.let { i -> closing?.let { i ->
var note by remember { mutableStateOf("") } FailureCloseDialog(i, r.data?.first?.second.orEmpty().filter { it.isActive }, onDismiss = { closing = null }) { req ->
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({
scope.launch { scope.launch {
try { client.api().closeIncident(i.id, CloseReq(note.trim())); closing = null; r.reload() } try {
catch (e: Throwable) { toast(ctx, e.humanMessage()) } 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 -> escalating?.let { i ->
var note by remember { mutableStateOf("") } 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)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Оборудование остановлено — машина получит состояние «Авария».", style = MaterialTheme.typography.bodySmall) Text("Оборудование остановлено — машина получит состояние «Авария».", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(note, { note = it }, label = { Text("Что случилось") }, minLines = 2, modifier = Modifier.fillMaxWidth()) OutlinedTextField(note, { note = it }, label = { Text("Что случилось") }, minLines = 2, modifier = Modifier.fillMaxWidth())
} }
}, confirmButton = { TextButton({ }, confirmButton = { TextButton({
scope.launch { 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()) } catch (e: Throwable) { toast(ctx, e.humanMessage()) }
} }
}) { Text("Перевести") } }, dismissButton = { TextButton({ escalating = null }) { Text("Отмена") } }) }) { 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 replaced by remember { mutableStateOf(false) }
var note by remember { mutableStateOf("") } var note by remember { mutableStateOf("") }
val picked = remember { mutableStateMapOf<Long, Boolean>() } // узел -> заменён 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)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (fault) item { Text("Оборудование работает, но замечена проблема. Если станет хуже — переведите сообщение в инцидент.", if (fault) item { Text("Оборудование работает, но замечена проблема. Если станет хуже — переведите сообщение в отказ.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) } style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) }
item { OutlinedTextField(title, { title = it }, label = { Text(if (fault) "Что замечено (кратко)" else "Что произошло (кратко)") }, modifier = Modifier.fillMaxWidth()) } 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()) } 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -45,6 +45,8 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
val error by repo.lastError.collectAsState() val error by repo.lastError.collectAsState()
var confirmLogout by remember { mutableStateOf(false) } var confirmLogout by remember { mutableStateOf(false) }
var unsynced by remember { mutableStateOf(0) } var unsynced by remember { mutableStateOf(0) }
var changePass by remember { mutableStateOf(false) }
val ctx = LocalContext.current
Scaffold(topBar = { Scaffold(topBar = {
ScreenBar(title = { Text("Настройки") }) 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 "") } 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?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор")
if (me?.isStorekeeper == 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("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp))
Text(repo.prefs.baseUrl) 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( if (confirmLogout) AlertDialog(
onDismissRequest = { confirmLogout = false }, onDismissRequest = { confirmLogout = false },
title = { Text("Выйти из аккаунта?") }, title = { Text("Выйти из аккаунта?") },
@@ -100,7 +100,7 @@ fun AppDrawer(current: String?, go: (String) -> Unit) {
item("orders", "📋", "Наряды") item("orders", "📋", "Наряды")
item("tasks", "🗓", "Задачи и поручения") item("tasks", "🗓", "Задачи и поручения")
group("Оборудование") group("Оборудование")
item("equipment", "❗", "Оборудование и инциденты") item("equipment", "❗", "Оборудование и отказы")
if (eng) item("state", "🔧", "Состояние узлов") if (eng) item("state", "🔧", "Состояние узлов")
if (me?.craneMap == true) { if (me?.craneMap == true) {
group("Краны") group("Краны")
@@ -112,7 +112,7 @@ fun WorkScreen(go: (String) -> Unit) {
} }
Scaffold(topBar = { 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 "⟳") } }) actions = { TextButton({ repo.syncSoon(); scope.launch { tasks.reload(); counts.reload() }; tick++ }, enabled = !syncing) { Text(if (syncing) "…" else "⟳") } })
}) { pad -> }) { pad ->
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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")) } 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 "🗺 На карте") 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 }) { .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() 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}") if (r.error != null) Empty("⚠ ${r.error}")
else if (r.data == null) Empty("Загрузка…") 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") } } } l.take(SHOW).forEach { i -> IncidentLine(i) { i.equipmentId?.let { go("equipment/$it") } } }
More(l.size) { go("equipment") } 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, if (scale > 1f) Text("Масштаб ${(scale * 100).toInt()}%: двигайте карту пальцем, «⤢» — весь план", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary) 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 } val unplaced = hit.filter { it.x == null }
if (unplaced.isNotEmpty()) Text("Не размещены на карте: " + unplaced.joinToString { it.name }, style = MaterialTheme.typography.bodySmall, if (unplaced.isNotEmpty()) Text("Не размещены на карте: " + unplaced.joinToString { it.name }, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary) color = MaterialTheme.colorScheme.secondary)
+1
View File
@@ -45,6 +45,7 @@
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке. - Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене. - График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server. - Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
- Смена своего пароля: своя карточка (имя вверху меню, #/user/<свой id>) → блок «🔑 Пароль» → «Сменить пароль»; в приложении Android — «Настройки» → «Сменить пароль». Нужен текущий пароль; другие устройства выйдут из системы. Забытый пароль сбрасывает администратор или модератор в #/users. При входе значок 👁 показывает введённый пароль.
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках. - Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест. - Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить. - Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
+14 -1
View File
@@ -180,6 +180,19 @@ async function route() {
} }
window.addEventListener("hashchange", 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() { function loginPage() {
$top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren(); $top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren();
const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" }); const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" });
@@ -205,7 +218,7 @@ function loginPage() {
}).catch(() => {}); }).catch(() => {});
fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).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, $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)); 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} .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{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} .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: "#", 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("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"),
h("div", { class: "navh" }, "Портал (нужна связь)"), 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", "🛒", "Заявки на инструмент"), web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"),
store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null, store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null,
web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"), 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; }; 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) { function loginView(my) {
setBar(h("h1", {}, "Чек-листы")); setBar(h("h1", {}, "Чек-листы"));
const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" }); 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; const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone;
commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"), 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, 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, ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null,
h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта")))); 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" }, 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, h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "edit")) }, "✎ Данные отказа")) : null,
i.status === "open" ? h("div", { class: "row", style: "margin-top:8px" }, 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 () => { f ? h("button", { class: "bad sm", title: "Неисправность привела к остановке оборудования", onclick: guard(async () => {
const note = prompt("Оборудование остановлено? Перевести сообщение в отказ (машина получит состояние «Отказ»). Что случилось:", ""); if (note === null) return; const note = prompt("Оборудование остановлено? Перевести сообщение в отказ (машина получит состояние «Отказ»). Что случилось:", ""); if (note === null) return;
await api("POST", `/incidents/${i.id}/escalate`, { note }); toast("Переведено в отказ"); onchange(); await api("POST", `/incidents/${i.id}/escalate`, { note }); toast("Переведено в отказ"); onchange();
@@ -808,6 +808,29 @@ async function peoplePage() {
draw(); 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) { async function userCardPage(id) {
const c = await api("GET", `/users/${id}/card`); const c = await api("GET", `/users/${id}/card`);
const u = c.user, reload = () => userCardPage(id); 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.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.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)), 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: "cols" },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"), 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, 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} .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{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)} .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 # Готовый 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/` (загруженный вручную файл важнее). и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее).
SHA-256: `d5764868c000bdd081a5c71ed215c516738f266b8e8a2eea0bfd0836b1209aaf` SHA-256: `49ed6928967ab665c7171ce64ed1491ab3d1245d03928c74fe2437630ab0db8e`
Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто
положите новый файл в том `appdata` по пути `apk/` (без передеплоя). положите новый файл в том `appdata` по пути `apk/` (без передеплоя).
+17 -6
View File
@@ -386,7 +386,8 @@ def build():
"он зададит новый.") "он зададит новый.")
pdf.h2("Вход на сайте") pdf.h2("Вход на сайте")
pdf.wide("d_login", "Страница входа", width=120) pdf.wide("d_login", "Страница входа", width=120)
pdf.p("Введите логин и пароль и нажмите «Войти». Если администратор загрузил приложение для Android, под кнопкой будет " pdf.p("Введите логин и пароль и нажмите «Войти». Значок-глаз в поле пароля показывает введённое (ещё раз — скрывает): удобно "
"проверить пароль перед входом. Если администратор загрузил приложение для Android, под кнопкой будет "
"ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток " "ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток "
"подряд вход на 5 минут блокируется — подождите и повторите.") "подряд вход на 5 минут блокируется — подождите и повторите.")
@@ -666,11 +667,11 @@ def build():
"Отметка «заменён» у узла **сбрасывает его ресурс**, как ручной сброс: остаток считается заново. В истории узла появится " "Отметка «заменён» у узла **сбрасывает его ресурс**, как ручной сброс: остаток считается заново. В истории узла появится "
"запись «замена при отказе».", "запись «замена при отказе».",
"Пока отказ открыт, машина в состоянии **«Авария»**; инженерам и администраторам приходит уведомление.", "Пока отказ открыт, машина в состоянии **«Авария»**; инженерам и администраторам приходит уведомление.",
"Когда неисправность устранена, автор или инженер нажимает **«Закрыть отказ»** и пишет, как устранено.", "Когда машину починили, автор или инженер нажимает крупную зелёную кнопку **«✔ Закрыть отказ»** и заполняет форму закрытия (ниже).",
"Все отказы — раздел **«Отказы»** (открытые и вся история).", "Все отказы — раздел **«Отказы»** (открытые и вся история).",
]) ])
pdf.wide("d_incident", "Фиксация отказа") pdf.wide("d_incident", "Фиксация отказа")
pdf.phones2("a_equipment", "Машина в приложении Android", "a_incident", "Отказ в приложении (там он пока называется «Инцидент»)") pdf.phones2("a_equipment", "Машина в приложении Android", "a_incident", "Отказ в приложении")
pdf.h2("Закрытие отказа: данные для анализа надёжности") pdf.h2("Закрытие отказа: данные для анализа надёжности")
pdf.p("Когда машину починили и запустили, автор отказа или инженер нажимает **«✔ Закрыть отказ»**. Открывается короткая форма: " pdf.p("Когда машину починили и запустили, автор отказа или инженер нажимает **«✔ Закрыть отказ»**. Открывается короткая форма: "
"почти всё в ней уже подставлено, поэтому дежурному обычно достаточно трёх действий.") "почти всё в ней уже подставлено, поэтому дежурному обычно достаточно трёх действий.")
@@ -684,8 +685,12 @@ def build():
"Инженер может позже **уточнить данные** закрытого отказа кнопкой «✎ Данные отказа».", "Инженер может позже **уточнить данные** закрытого отказа кнопкой «✎ Данные отказа».",
]) ])
pdf.wide("d_failure_close", "Закрытие отказа") pdf.wide("d_failure_close", "Закрытие отказа")
pdf.note("Чем точнее заполнены причина и время, тем полезнее раздел «Анализ надёжности». Если отказ закрыли из приложения для Android, " pdf.p("В **приложении для Android** форма та же: что сделано, характер отказа, описание, начало работ и ввод в эксплуатацию "
"сервер заполнит время и наработку сам, а инженер может дополнить причину на сайте.", "info") "(кнопка «Сейчас» подставляет текущее время), ожидание запчастей, наработка, показание счётчика, узел-причина, причина, признаки "
"заранее и заменённые узлы. Отказы, закрытые с телефона, попадают в анализ надёжности полностью.")
pdf.phone("a_failure_close", "Закрытие отказа в приложении Android", width=70)
pdf.note("Чем точнее заполнены причина и время, тем полезнее раздел «Анализ надёжности». Инженер может уточнить данные закрытого "
"отказа на сайте кнопкой «✎ Данные отказа».", "info")
pdf.h2("Сообщение о неисправности") pdf.h2("Сообщение о неисправности")
pdf.p("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё " pdf.p("Не каждая проблема останавливает оборудование. Если замечен посторонний шум, вибрация, подтекание или нагрев, а машина ещё "
"работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). " "работает, нажмите в её карточке **«⚠ Сообщить о неисправности»**: что замечено, подробности, какой узел (если известно). "
@@ -839,6 +844,11 @@ def build():
pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); " pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); "
"модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому " "модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому "
"сотруднику, складу и инженерам.", "info") "сотруднику, складу и инженерам.", "info")
pdf.h2("Смена своего пароля")
pdf.p("Сменить свой пароль можно самому, без администратора. На сайте: нажмите своё имя вверху бокового меню → блок **«Пароль»** → "
"«Сменить пароль»: текущий пароль, новый (не короче 10 символов) и ещё раз новый. В приложении для Android: «Настройки» → "
"**«Сменить пароль»**. На этом устройстве вы останетесь в системе, а на остальных (и в приложении на телефоне) нужно будет "
"войти заново с новым паролем. Забытый пароль сбрасывает администратор или модератор."),
# ================= 6 ================= # ================= 6 =================
pdf.h1("13. Администратор и модератор") pdf.h1("13. Администратор и модератор")
@@ -1027,7 +1037,8 @@ def build():
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы " "У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
"со статусом «Скоро» и «Требуется» показаны вверху списка."), "со статусом «Скоро» и «Требуется» показаны вверху списка."),
("Я забыл пароль.", ("Я забыл пароль.",
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."), "Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль. Затем смените его на свой: "
"своя карточка → «Пароль» (в приложении — «Настройки» → «Сменить пароль»)."),
("Время в нарядах отличается от времени на телефоне.", ("Время в нарядах отличается от времени на телефоне.",
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас " "Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
"другой часовой пояс."), "другой часовой пояс."),