Compare commits
2
Commits
849645076c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cebf300aa | ||
|
|
4f2aed1770 |
@@ -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)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
|
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
|
||||||
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
|
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
|
||||||
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
|
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
|
||||||
|
- Смена своего пароля: своя карточка (имя вверху меню, #/user/<свой id>) → блок «🔑 Пароль» → «Сменить пароль»; в приложении Android — «Настройки» → «Сменить пароль». Нужен текущий пароль; другие устройства выйдут из системы. Забытый пароль сбрасывает администратор или модератор в #/users. При входе значок 👁 показывает введённый пароль.
|
||||||
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
|
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
|
||||||
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
|
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
|
||||||
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
|
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
|
||||||
|
|||||||
+14
-1
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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/` (без передеплоя).
|
||||||
|
|||||||
Binary file not shown.
@@ -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():
|
|||||||
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
|
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
|
||||||
"со статусом «Скоро» и «Требуется» показаны вверху списка."),
|
"со статусом «Скоро» и «Требуется» показаны вверху списка."),
|
||||||
("Я забыл пароль.",
|
("Я забыл пароль.",
|
||||||
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."),
|
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль. Затем смените его на свой: "
|
||||||
|
"своя карточка → «Пароль» (в приложении — «Настройки» → «Сменить пароль»)."),
|
||||||
("Время в нарядах отличается от времени на телефоне.",
|
("Время в нарядах отличается от времени на телефоне.",
|
||||||
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
|
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
|
||||||
"другой часовой пояс."),
|
"другой часовой пояс."),
|
||||||
|
|||||||
Reference in New Issue
Block a user