Files
portal/app/resources/js/components/admin/SystemSettingEditDialog.vue
T
Дмитрий 768628d914 phase2(7-features): bulk-actions / new-deal / tenant-card / system-edit / webhook / smart-filters / impersonation-list
7-фичный auto-mode пакет согласно «карте что осталось» (после v1.54).

(1) Bulk-actions DealsView:
- dealsState reactive-копия MOCK_DEALS (deep-clone) для безопасного bulk-edit.
- Bulk-bar (sticky, теало-нуар, theme=dark) при selected.length > 0:
  count + Сменить статус (v-menu × 14 lead_statuses) + Экспорт (snackbar) +
  Удалить (v-dialog confirm) + ✕ clear.
- На production: smart status-transition с проверкой allowed-переходов;
  soft-delete (архив 30 дней); реальный CSV/XLSX export через xlsx-lib.

(2) NewDealDialog (used in DealsView+KanbanView):
- 6 полей: name/phone/project (MOCK_PROJECTS) / manager (MOCK_MANAGERS) /
  cost / status (default 'new' или presetStatus). Phone-валидация ≥10 цифр.
- emit('created', deal) → DealsView push в начало dealsState; KanbanView push
  в правильную колонку по statusSlug + totalDeals++.

(3) AdminTenantDetailView (/admin/tenants/:code):
- 4 KPI cards (Баланс/runway / Тариф+MRR/мес / Лиды сегодня+неделя+месяц /
  Средняя цена). 4 v-tabs: Финансы (balance-history) / Пользователи /
  Проекты / Активность с event-кодами.
- Кнопка «Войти как клиент» (использует ImpersonationDialog из v1.54).
  404-fallback. composables/mockTenantDetail.ts с expandTenantDetail.
- AdminTenantsView получил @click:row → router.push.

(4) Edit-flow AdminSystemView (audit-log + 2-step):
- Backend: SystemSetting + SaasAdminAuditLog Eloquent (append-only,
  payload_before/after JSONB casts).
- AdminSystemSettingsController с GET (list) + PUT (update в DB::transaction
  + INSERT в saas_admin_audit_log; hash-chain trigger BEFORE INSERT
  заполняет log_hash).
- Type-validation: int/decimal/bool/json. Reason ≥30 chars. No-op → 422.
- Frontend SystemSettingEditDialog — 3-step (edit → confirm с diff
  before/after → done).

(5) Webhook receive endpoint (POST /api/webhook/{token}):
- WebhookReceiveController::receive. Token = tenants.webhook_token.
- 404 unknown / 422 bad payload / 202 success + dispatch ProcessWebhookJob.
- Stub-INSERT в webhook_log через DB::table обёрнут в DB::transaction +
  SET LOCAL app.current_tenant_id для RLS.
- CSRF-исключение для api/webhook/* в bootstrap/app.php.
- На prod: + HMAC X-Webhook-Signature + per-token rate-limit.

(6) Smart-filters:
- DealsView: multi-select v-select Проект+Менеджер с auto availableProjects/
  availableManagers computed.
- AdminTenantsView: filterStatuses (4 STATUS_OPTIONS) + filterTariffs
  (computed availableTariffs).
- Кнопка «Сбросить» появляется только когда фильтры активны.

(7) AdminImpersonationView (/admin/impersonation):
- Backend +2 GET endpoints: /active (used_at != null AND session_ended_at
  == null) + /recent (last 20 завершённых с duration_seconds через
  abs(diffInSeconds) — Carbon signed по умолчанию).
- ImpersonationToken получил belongsTo(Tenant).
- Frontend view: 2 секции (Активные с end-кнопкой / Недавно завершённые
  read-only) + refresh + onMounted load.
- Маршрут /admin/impersonation + 5-й nav-пункт «Impersonation» в AdminLayout.

Vitest +48 (всего 238/238 за 15.31 сек).
Pest +16 (всего 136/136 за 15.8 сек, 495 assertions).
PHPStan baseline регенерирован (0 errors после фикса nullsafe.neverNull).

Регресс: lint+type-check+format ; vite build 937 ms; Pint+PHPStan passed;
Pest 136/136. Реестр v1.54→v1.55, CLAUDE.md v1.45→v1.46.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 05:33:21 +03:00

253 lines
9.4 KiB
Vue

<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>