Files
portal/app/resources/js/components/admin/SystemSettingEditDialog.vue
T

253 lines
9.4 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/**
* Диалог редактирования system_setting (двухстадийное подтверждение + audit-log).
*
* Step 1 «edit»: новое значение + reason ≥30 chars + кнопка «Далее».
* Step 2 «confirm»: показ before/after + ещё раз подтверждение.
*
* На submit → PUT /api/admin/system-settings/{key} (audit-log пишется
* автоматически на backend). На success — emit('updated') чтобы parent
* перезагрузил список.
*/
import * as adminApi from '../../api/admin';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { computed, ref, watch } from 'vue';
const props = defineProps<{
modelValue: boolean;
setting: adminApi.SystemSetting | null;
requestedBy: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'updated', payload: adminApi.UpdateSystemSettingResponse): void;
}>();
type Step = 'edit' | 'confirm' | 'done';
const step = ref<Step>('edit');
const newValue = ref('');
const reason = ref('');
const valueError = ref<string | null>(null);
const reasonError = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const busy = ref(false);
const dialogOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const reasonValid = computed(() => reason.value.trim().length >= 30);
const reasonRemaining = computed(() => Math.max(0, 30 - reason.value.trim().length));
function reset() {
step.value = 'edit';
newValue.value = props.setting?.value ?? '';
reason.value = '';
valueError.value = null;
reasonError.value = null;
errorMessage.value = null;
busy.value = false;
}
watch(
() => props.modelValue,
(open) => {
if (open) reset();
},
{ immediate: true },
);
function goConfirm() {
valueError.value = null;
reasonError.value = null;
if (props.setting && newValue.value === props.setting.value) {
valueError.value = 'Новое значение совпадает со старым.';
return;
}
if (!reasonValid.value) {
reasonError.value = 'Минимум 30 символов.';
return;
}
step.value = 'confirm';
}
async function submit() {
if (!props.setting) return;
busy.value = true;
errorMessage.value = null;
try {
const r = await adminApi.updateSystemSetting(props.setting.key, {
value: newValue.value,
reason: reason.value.trim(),
admin_user_id: props.requestedBy,
});
emit('updated', r);
step.value = 'done';
} catch (err) {
const validation = extractValidationErrors(err);
if (validation?.value?.[0]) {
valueError.value = validation.value[0];
step.value = 'edit';
} else if (validation?.reason?.[0]) {
reasonError.value = validation.reason[0];
step.value = 'edit';
} else {
errorMessage.value = extractErrorMessage(err, 'Не удалось обновить настройку.');
}
} finally {
busy.value = false;
}
}
function close() {
dialogOpen.value = false;
}
defineExpose({ step, newValue, reason, valueError, reasonError });
</script>
<template>
<v-dialog v-model="dialogOpen" :max-width="540" persistent data-testid="system-setting-edit-dialog">
<v-card v-if="setting">
<v-card-title class="d-flex align-center">
<v-icon class="me-2" color="warning">mdi-cog-outline</v-icon>
Изменить настройку
</v-card-title>
<v-card-subtitle class="pb-2">
<span class="font-mono">{{ setting.key }}</span>
<v-chip class="ml-2" size="x-small" variant="tonal">{{ setting.type }}</v-chip>
</v-card-subtitle>
<v-card-text>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="error-alert"
>
{{ errorMessage }}
</v-alert>
<!-- STEP 1: edit -->
<template v-if="step === 'edit'">
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Изменение настройки будет записано в <code>saas_admin_audit_log</code> и попадёт в журнал
SaaS-админов. Действия неотменяемы только новое изменение.
</v-alert>
<p class="text-body-2 text-medium-emphasis mb-3">{{ setting.description }}</p>
<div class="text-caption text-medium-emphasis mb-1">Текущее значение:</div>
<div class="value-block font-mono mb-3">{{ setting.value }}</div>
<v-text-field
v-model="newValue"
label="Новое значение"
variant="outlined"
density="comfortable"
:error-messages="valueError ? [valueError] : []"
data-testid="value-input"
/>
<v-textarea
v-model="reason"
label="Основание (≥30 символов)"
variant="outlined"
rows="3"
:counter="200"
:error-messages="reasonError ? [reasonError] : []"
:hint="reasonValid ? 'Готово' : `Ещё ${reasonRemaining} символов`"
persistent-hint
placeholder="Решение CTO от 09.05.2026: ослабляем лимит для UX тестирования."
data-testid="reason-input"
/>
</template>
<!-- STEP 2: confirm -->
<template v-else-if="step === 'confirm'">
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
Подтвердите изменение. После «Применить» backend обновит настройку и запишет в audit-log.
</v-alert>
<div class="diff-block">
<div class="diff-row">
<div class="diff-label text-caption text-medium-emphasis">Было:</div>
<div class="value-block font-mono diff-before">{{ setting.value }}</div>
</div>
<div class="diff-row">
<div class="diff-label text-caption text-medium-emphasis">Станет:</div>
<div class="value-block font-mono diff-after">{{ newValue }}</div>
</div>
<div class="diff-row">
<div class="diff-label text-caption text-medium-emphasis">Основание:</div>
<div class="reason-block">{{ reason.trim() }}</div>
</div>
</div>
</template>
<!-- STEP 3: done -->
<template v-else-if="step === 'done'">
<v-alert type="success" variant="tonal" density="compact">
Настройка обновлена и записана в audit-log.
</v-alert>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<template v-if="step === 'edit'">
<v-btn variant="text" :disabled="busy" data-testid="cancel-btn" @click="close">Отмена</v-btn>
<v-btn color="primary" data-testid="next-btn" @click="goConfirm">Далее</v-btn>
</template>
<template v-else-if="step === 'confirm'">
<v-btn variant="text" :disabled="busy" data-testid="back-btn" @click="step = 'edit'">Назад</v-btn>
<v-btn color="primary" :loading="busy" data-testid="submit-btn" @click="submit"> Применить </v-btn>
</template>
<template v-else>
<v-btn color="primary" data-testid="close-btn" @click="close">Закрыть</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.value-block {
background: #f6f3ec;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
word-break: break-all;
}
.diff-block {
display: flex;
flex-direction: column;
gap: 12px;
}
.diff-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.diff-before {
background: #fde8e8;
color: #842029;
text-decoration: line-through;
}
.diff-after {
background: #d8f1e6;
color: #0a4029;
}
.reason-block {
background: #fff8e6;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
border-left: 3px solid #b88a00;
}
</style>