Files
portal/app/resources/js/views/settings/ProfileTab.vue
T
Дмитрий 5f209a2fcc fix(ui): косметика UI-аудита — даты дд.мм.гггг, инлайн-валидация, формат денег, aria, тосты, статус-метки, админка
Раунд 2 минор-фиксы (Playwright-аудит):
- RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного
  <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки.
- BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi.
- dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками
  (было «viewed → new» сырыми слагами).
- ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах).
- RequisitesTab: проверка формата телефона на клиенте.
- ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет).
- DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0.
- KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост).
- NotificationsTab: убран жаргон «users.notification_preferences в БД».
- Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers
  epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов
  нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar.

Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:08:51 +03:00

187 lines
6.9 KiB
Vue

<script setup lang="ts">
/**
* Settings → Профиль. Имя, фамилия, телефон, тайм-зона текущего пользователя
* из auth-store; сохранение через PATCH /api/auth/me (audit D1 / J6).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
* Email — read-only (меняется через support). Поле «Роль» убрано: в таблице
* users нет колонки роли (было декоративной mock-заглушкой). Кнопка смены
* аватара пока без обработчика — загрузка аватара вне scope audit D1.
*/
import { computed, ref } from 'vue';
import { useAuthStore } from '../../stores/auth';
import { updateProfile } from '../../api/auth';
import { extractErrorMessage } from '../../api/client';
const auth = useAuthStore();
// auth.user гарантированно не null здесь: router beforeEach дожидается
// fetchMe() до навигации на requiresAuth-маршруты (см. router/index.ts).
// Поэтому watch-ресинк (как в NotificationsTab) не нужен.
const firstName = ref(auth.user?.first_name ?? '');
const lastName = ref(auth.user?.last_name ?? '');
const phone = ref(auth.user?.phone ?? '');
const timezone = ref(auth.user?.timezone ?? 'Europe/Moscow');
const email = computed(() => auth.user?.email ?? '');
const initials = computed(() => {
const f = (auth.user?.first_name ?? '').charAt(0);
const l = (auth.user?.last_name ?? '').charAt(0);
return (f + l).toUpperCase() || '—';
});
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref<string | null>(null);
function resetForm(): void {
firstName.value = auth.user?.first_name ?? '';
lastName.value = auth.user?.last_name ?? '';
phone.value = auth.user?.phone ?? '';
timezone.value = auth.user?.timezone ?? 'Europe/Moscow';
saveSuccess.value = false;
saveError.value = null;
}
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
const updated = await updateProfile({
first_name: firstName.value,
last_name: lastName.value,
phone: phone.value.trim() === '' ? null : phone.value.trim(),
timezone: timezone.value,
});
auth.user = { ...auth.user!, ...updated };
saveSuccess.value = true;
} catch (err) {
saveError.value = extractErrorMessage(err, 'Не удалось сохранить профиль.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Профиль</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-success"
@click:close="saveSuccess = false"
>
Профиль сохранён.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-error"
@click:close="saveError = null"
>
{{ saveError }}
</v-alert>
<v-row class="profile-row">
<v-col cols="auto">
<v-avatar size="80" color="primary">
<span class="text-h5">{{ initials }}</span>
</v-avatar>
</v-col>
<v-col>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="firstName"
label="Имя"
variant="outlined"
density="comfortable"
:rules="[(v) => !!(v && v.trim()) || 'Укажите имя']"
data-testid="profile-first-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="lastName"
label="Фамилия"
variant="outlined"
density="comfortable"
:rules="[(v) => !!(v && v.trim()) || 'Укажите фамилию']"
data-testid="profile-last-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
:model-value="email"
label="Email"
type="email"
variant="outlined"
density="comfortable"
disabled
persistent-hint
hint="Email менять только через support — связан с авторизацией"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="phone"
label="Телефон"
variant="outlined"
density="comfortable"
data-testid="profile-phone"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="timezone"
label="Тайм-зона"
variant="outlined"
density="comfortable"
persistent-hint
hint="Используется в логах"
data-testid="profile-timezone"
/>
</v-col>
</v-row>
<div class="d-flex ga-2 mt-4">
<v-btn
color="primary"
variant="flat"
:loading="saving"
data-testid="profile-save-btn"
@click="save"
>
Сохранить
</v-btn>
<v-btn variant="text" :disabled="saving" data-testid="profile-cancel-btn" @click="resetForm">
Отмена
</v-btn>
</div>
</v-col>
</v-row>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
.profile-row {
align-items: flex-start;
}
</style>