Files
portal/app/resources/js/views/settings/RequisitesTab.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

392 lines
15 KiB
Vue

<script setup lang="ts">
/**
* Settings → Реквизиты (G1/SP3b). «Лёгкая» форма: тип лица + контакт + ИНН.
*
* Снимает гейт первого проекта (ProjectController::store → 422 requisites_required,
* isLightComplete). Полные поля (КПП/ОГРН/юр.адрес/банк) — SP3c, дополнит эту же
* вкладку позже. DaData на dev выключена → lookup-inn возвращает found:false.
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { getRequisites, lookupInn, updateRequisites, type Requisites } from '../../api/requisites';
const form = reactive<Requisites>({
subject_type: null,
contact_name: null,
contact_phone: null,
inn: null,
legal_name: null,
kpp: null,
ogrn: null,
legal_address: null,
bank_name: null,
bank_bik: null,
bank_account: null,
corr_account: null,
requisites_completed_at: null,
});
const errors = ref<Record<string, string[]>>({});
const loading = ref(false);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref<string | null>(null);
const lookupLoading = ref(false);
const lookupMessage = ref('');
const lookupError = ref(false);
const subjectTypes = [
{ value: 'individual', label: 'Физлицо' },
{ value: 'sole_proprietor', label: 'ИП' },
{ value: 'legal_entity', label: 'Юрлицо' },
];
const requiresInn = computed(
() => form.subject_type === 'sole_proprietor' || form.subject_type === 'legal_entity',
);
const isLegalEntity = computed(() => form.subject_type === 'legal_entity');
const isSoleProprietor = computed(() => form.subject_type === 'sole_proprietor');
// Блок платёжных реквизитов виден, как только выбран тип лица.
const showPayment = computed(() => form.subject_type !== null);
// КПП — только юрлицо; ОГРН/ОГРНИП и юр.адрес — юрлицо и ИП; банк — всегда (когда showPayment).
const showKpp = computed(() => isLegalEntity.value);
const showOgrn = computed(() => isLegalEntity.value || isSoleProprietor.value);
const showLegalAddress = computed(() => isLegalEntity.value || isSoleProprietor.value);
const ogrnLabel = computed(() => (isSoleProprietor.value ? 'ОГРНИП' : 'ОГРН'));
const isCompleted = computed(() => Boolean(form.requisites_completed_at));
onMounted(async () => {
loading.value = true;
try {
const existing = await getRequisites();
if (existing) Object.assign(form, existing);
} catch (err) {
saveError.value = extractErrorMessage(err, 'Не удалось загрузить реквизиты.');
} finally {
loading.value = false;
}
});
async function handleLookup(): Promise<void> {
lookupMessage.value = '';
lookupError.value = false;
if (!form.inn || form.inn.trim() === '') return;
lookupLoading.value = true;
try {
const res = await lookupInn(form.inn.trim());
if (res.found) {
form.legal_name = res.legal_name ?? null;
form.kpp = res.kpp ?? null;
form.ogrn = res.ogrn ?? null;
form.legal_address = res.legal_address ?? null;
if (res.subject_type_hint) form.subject_type = res.subject_type_hint;
lookupMessage.value = `Найдено: ${res.legal_name ?? '—'}`;
} else {
lookupMessage.value = 'Организация не найдена по ИНН — проверьте номер или введите название вручную.';
lookupError.value = true;
}
} catch (err) {
lookupMessage.value = extractErrorMessage(err, 'Не удалось проверить ИНН.');
lookupError.value = true;
} finally {
lookupLoading.value = false;
}
}
function validateClient(): boolean {
const e: Record<string, string[]> = {};
if (!form.subject_type) e.subject_type = ['Выберите тип лица'];
if (!form.contact_name || form.contact_name.trim() === '') e.contact_name = ['Укажите контактное имя'];
const cp = form.contact_phone?.trim() ?? '';
if (cp === '') {
e.contact_phone = ['Укажите телефон'];
} else if (!/^(\+7|8)?\s*\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/.test(cp.replace(/\s/g, ''))) {
e.contact_phone = ['Телефон в формате +7XXXXXXXXXX'];
}
if (requiresInn.value && (!form.inn || form.inn.trim() === '')) e.inn = ['ИНН обязателен для ИП и юрлица'];
// Платёжные поля — необязательные; проверяем формат только если заполнены.
const kpp = form.kpp?.trim() ?? '';
if (kpp !== '' && !/^\d{9}$/.test(kpp)) e.kpp = ['КПП — 9 цифр'];
const ogrn = form.ogrn?.trim() ?? '';
if (ogrn !== '') {
const len = isSoleProprietor.value ? 15 : 13;
if (!new RegExp(`^\\d{${len}}$`).test(ogrn)) {
e.ogrn = [isSoleProprietor.value ? 'ОГРНИП — 15 цифр' : 'ОГРН — 13 цифр'];
}
}
const bik = form.bank_bik?.trim() ?? '';
if (bik !== '' && !/^\d{9}$/.test(bik)) e.bank_bik = ['БИК — 9 цифр'];
const acc = form.bank_account?.trim() ?? '';
if (acc !== '' && !/^\d{20}$/.test(acc)) e.bank_account = ['Расчётный счёт — 20 цифр'];
const corr = form.corr_account?.trim() ?? '';
if (corr !== '' && !/^\d{20}$/.test(corr)) e.corr_account = ['Корр. счёт — 20 цифр'];
errors.value = e;
return Object.keys(e).length === 0;
}
async function save(): Promise<void> {
if (saving.value) return;
saveSuccess.value = false;
saveError.value = null;
if (!validateClient()) return;
saving.value = true;
try {
const updated = await updateRequisites({ ...form });
Object.assign(form, updated);
errors.value = {};
saveSuccess.value = true;
} catch (err) {
const ve = extractValidationErrors(err);
if (ve) errors.value = ve;
else saveError.value = extractErrorMessage(err, 'Не удалось сохранить реквизиты.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-1">Реквизиты</h2>
<p class="text-body-2 text-medium-emphasis mb-4">
Нужны, чтобы создать первый проект. Полные реквизиты для оплаты заполняются позже.
</p>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="requisites-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="requisites-save-error"
@click:close="saveError = null"
>
{{ saveError }}
</v-alert>
<div class="mb-4">
<span class="text-body-2 text-medium-emphasis">Тип лица</span>
<v-btn-toggle
v-model="form.subject_type"
mandatory="force"
density="comfortable"
color="primary"
class="mt-1 d-block"
data-testid="requisites-subject-type"
>
<v-btn v-for="t in subjectTypes" :key="t.value" :value="t.value">{{ t.label }}</v-btn>
</v-btn-toggle>
<div v-if="errors.subject_type" class="text-caption text-error mt-1">
{{ errors.subject_type[0] }}
</div>
</div>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="form.contact_name"
label="Контактное имя"
variant="outlined"
density="comfortable"
:error-messages="errors.contact_name"
data-testid="requisites-contact-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.contact_phone"
label="Контактный телефон"
variant="outlined"
density="comfortable"
:error-messages="errors.contact_phone"
data-testid="requisites-contact-phone"
/>
</v-col>
<v-col v-if="requiresInn" cols="12" md="6">
<div class="d-flex ga-2 align-start">
<v-text-field
v-model="form.inn"
label="ИНН"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.inn"
data-testid="requisites-inn"
/>
<v-btn
variant="tonal"
color="primary"
class="mt-1"
:loading="lookupLoading"
data-testid="requisites-lookup-btn"
@click="handleLookup"
>
Найти по ИНН
</v-btn>
</div>
<div
v-if="lookupMessage"
class="text-caption mt-1"
:class="lookupError ? 'text-warning' : 'text-medium-emphasis'"
data-testid="requisites-lookup-msg"
>
{{ lookupMessage }}
</div>
</v-col>
<v-col v-if="requiresInn" cols="12" md="6">
<v-text-field
v-model="form.legal_name"
label="Название организации"
variant="outlined"
density="comfortable"
persistent-hint
hint="Подставится по ИНН или введите вручную"
data-testid="requisites-legal-name"
/>
</v-col>
</v-row>
<template v-if="showPayment">
<v-divider class="my-5" />
<div class="d-flex align-center ga-3 mb-1">
<h3 class="text-subtitle-1 ma-0">Реквизиты для оплаты</h3>
<v-chip
:color="isCompleted ? 'success' : undefined"
size="small"
variant="tonal"
data-testid="requisites-payment-status"
>
{{ isCompleted ? 'Готово к оплате' : 'Не заполнено' }}
</v-chip>
</div>
<p class="text-body-2 text-medium-emphasis mb-4">
Нужны для выставления счёта и договора. Можно заполнить позже.
</p>
<v-row dense>
<v-col v-if="showKpp" cols="12" md="6">
<v-text-field
v-model="form.kpp"
label="КПП"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.kpp"
data-testid="requisites-kpp"
/>
</v-col>
<v-col v-if="showOgrn" cols="12" md="6">
<v-text-field
v-model="form.ogrn"
:label="ogrnLabel"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.ogrn"
data-testid="requisites-ogrn"
/>
</v-col>
<v-col v-if="showLegalAddress" cols="12">
<v-text-field
v-model="form.legal_address"
label="Юридический адрес"
variant="outlined"
density="comfortable"
:error-messages="errors.legal_address"
data-testid="requisites-legal-address"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.bank_name"
label="Наименование банка"
variant="outlined"
density="comfortable"
:error-messages="errors.bank_name"
data-testid="requisites-bank-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.bank_bik"
label="БИК"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.bank_bik"
data-testid="requisites-bank-bik"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.bank_account"
label="Расчётный счёт"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.bank_account"
data-testid="requisites-bank-account"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.corr_account"
label="Корреспондентский счёт"
inputmode="numeric"
variant="outlined"
density="comfortable"
:error-messages="errors.corr_account"
data-testid="requisites-corr-account"
/>
</v-col>
</v-row>
</template>
<div class="d-flex ga-2 mt-4">
<v-btn
color="primary"
variant="flat"
:loading="saving"
data-testid="requisites-save-btn"
@click="save"
>
Сохранить
</v-btn>
</div>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
</style>