768628d914
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>
253 lines
9.4 KiB
Vue
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>
|