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>
392 lines
15 KiB
Vue
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>
|