5f209a2fcc
Раунд 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>
187 lines
6.9 KiB
Vue
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>
|