# G1/SP3b — форма реквизитов (фронт) + UX гейта — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Дать клиенту вкладку «Реквизиты» в Настройках для ввода лёгких реквизитов и понятно провести его через гейт первого проекта (`422 requisites_required`).

**Architecture:** Тонкая api-обёртка (`api/requisites.ts`) над тремя готовыми бэкенд-эндпоинтами; новая Vuetify-вкладка `RequisitesTab.vue` (паттерн как `ProfileTab.vue`); вкладка регистрируется в `SettingsView.vue` с deep-link `?tab=requisites`; гейт обрабатывается в `NewProjectDialog.vue` (alert + кнопка-переход).

**Tech Stack:** Vue 3 `<script setup lang="ts">` + Vuetify 3 + axios (`apiClient`) + vue-router.

> **⚠ Тестовый раннер:** vitest на фронте сломан (G8) — Vue-юнитов нет. TDD-цикл заменён на: `vue-tsc` (type-check), eslint по изменённым файлам, и **живой Playwright** (Task 6, обязательно по требованию владельца). Это сознательное отступление, зафиксированное в спеке §6.

**Spec:** [docs/superpowers/specs/2026-06-19-g1-sp3b-requisites-frontend-design.md](../specs/2026-06-19-g1-sp3b-requisites-frontend-design.md)

---

## File Structure

| Файл | Ответственность |
|---|---|
| `app/resources/js/api/requisites.ts` | **новый** — `getRequisites` / `updateRequisites` / `lookupInn` поверх `apiClient` |
| `app/resources/js/views/settings/RequisitesTab.vue` | **новый** — лёгкая форма реквизитов |
| `app/resources/js/views/SettingsView.vue` | **правка** — +вкладка «Реквизиты», +deep-link `?tab=requisites` |
| `app/resources/js/views/projects/NewProjectDialog.vue` | **правка** — ветка гейта `requisites_required` + alert + кнопка-переход |

---

## Task 1: API-обёртка реквизитов

**Files:**
- Create: `app/resources/js/api/requisites.ts`

- [ ] **Step 1: Создать файл api/requisites.ts**

```ts
import { apiClient, ensureCsrfCookie } from './client';

/** Реквизиты тенанта. Совпадает с RequisitesResource (бэкенд SP2). */
export interface Requisites {
    subject_type: 'individual' | 'sole_proprietor' | 'legal_entity' | null;
    contact_name: string | null;
    contact_phone: string | null;
    inn: string | null;
    legal_name: string | null;
    kpp: string | null;
    ogrn: string | null;
    legal_address: string | null;
    bank_name: string | null;
    bank_bik: string | null;
    bank_account: string | null;
    corr_account: string | null;
    requisites_completed_at: string | null;
}

/** Ответ POST /lookup-inn (мягкая подтяжка по DaData). */
export interface InnLookupResult {
    found: boolean;
    legal_name?: string;
    kpp?: string;
    ogrn?: string;
    legal_address?: string;
    subject_type_hint?: 'sole_proprietor' | 'legal_entity';
}

/** GET /api/tenant/requisites — null если ещё не заполнялись. */
export async function getRequisites(): Promise<Requisites | null> {
    const { data } = await apiClient.get('/api/tenant/requisites');
    return data.data ?? null;
}

/** PUT /api/tenant/requisites — upsert, возвращает сохранённое. */
export async function updateRequisites(payload: Partial<Requisites>): Promise<Requisites> {
    await ensureCsrfCookie();
    const { data } = await apiClient.put('/api/tenant/requisites', payload);
    return data.data;
}

/** POST /api/tenant/requisites/lookup-inn — ничего не сохраняет. */
export async function lookupInn(inn: string): Promise<InnLookupResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post('/api/tenant/requisites/lookup-inn', { inn });
    return data;
}
```

- [ ] **Step 2: Type-check**

Run: `npm --prefix app run type-check`
Expected: exit 0 (новый файл не ломает типы; ещё не импортируется — это нормально).

- [ ] **Step 3: Commit**

```bash
git add app/resources/js/api/requisites.ts
LEFTHOOK=0 git commit -m "feat(G1/SP3b): api-обёртка реквизитов (get/update/lookup-inn)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: Вкладка RequisitesTab.vue (лёгкая форма)

**Files:**
- Create: `app/resources/js/views/settings/RequisitesTab.vue`

- [ ] **Step 1: Создать RequisitesTab.vue**

```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',
);

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 = ['Укажите контактное имя'];
    if (!form.contact_phone || form.contact_phone.trim() === '') e.contact_phone = ['Укажите телефон'];
    if (requiresInn.value && (!form.inn || form.inn.trim() === '')) e.inn = ['ИНН обязателен для ИП и юрлица'];
    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>

        <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>
```

- [ ] **Step 2: Type-check + eslint**

Run: `npm --prefix app run type-check`
Expected: exit 0.
Run: `npx --prefix app eslint resources/js/views/settings/RequisitesTab.vue` (из каталога `app`)
Expected: 0 ошибок.

- [ ] **Step 3: Commit**

```bash
git add app/resources/js/views/settings/RequisitesTab.vue
LEFTHOOK=0 git commit -m "feat(G1/SP3b): вкладка RequisitesTab — лёгкая форма реквизитов

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: Регистрация вкладки в SettingsView + deep-link

**Files:**
- Modify: `app/resources/js/views/SettingsView.vue`

- [ ] **Step 1: Импорт RequisitesTab + router + onMounted**

Заменить блок импортов (строки 15–19):

```ts
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import ApiTab from './settings/ApiTab.vue';
import NotificationsTab from './settings/NotificationsTab.vue';
import ProfileTab from './settings/ProfileTab.vue';
import RequisitesTab from './settings/RequisitesTab.vue';
import SecurityTab from './settings/SecurityTab.vue';
```

- [ ] **Step 2: Добавить вкладку в массив tabs**

В массиве `tabs` после строки `{ id: 'profile', ... }` добавить:

```ts
    { id: 'requisites', label: 'Реквизиты', icon: 'mdi-file-document-outline' },
```

- [ ] **Step 3: Deep-link — читать ?tab при монтировании**

После `const activeTab = ref('profile');` добавить:

```ts
const route = useRoute();
onMounted(() => {
    const t = route.query.tab;
    if (typeof t === 'string' && tabs.some((tab) => tab.id === t)) {
        activeTab.value = t;
    }
});
```

- [ ] **Step 4: Отрисовать RequisitesTab в tab-pane**

В шаблоне (`tab-pane`) после строки `<ProfileTab v-if="activeTab === 'profile'" />` добавить:

```html
                    <RequisitesTab v-else-if="activeTab === 'requisites'" />
```

(Остальные `v-else-if` остаются ниже без изменений.)

- [ ] **Step 5: Type-check + eslint**

Run: `npm --prefix app run type-check`
Expected: exit 0.
Run: `npx --prefix app eslint resources/js/views/SettingsView.vue`
Expected: 0 ошибок.

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/views/SettingsView.vue
LEFTHOOK=0 git commit -m "feat(G1/SP3b): вкладка Реквизиты в Настройках + deep-link ?tab=requisites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: UX гейта в NewProjectDialog

**Files:**
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`

- [ ] **Step 1: Импорт useRouter**

В блоке `<script setup>` после строки `import { ref, reactive, watch } from 'vue';` добавить:

```ts
import { useRouter } from 'vue-router';
```

- [ ] **Step 2: router + флаг гейта**

После `const emit = defineEmits...` блока (перед `const form = reactive`) добавить:

```ts
const router = useRouter();
```

После `const generalError = ref<string | null>(null);` добавить:

```ts
// G1/SP3b: гейт первого проекта — бэкенд 422 {error:'requisites_required'}.
const requisitesRequired = ref(false);
```

- [ ] **Step 3: Сбрасывать флаг при открытии окна**

В `watch(() => props.modelValue, (open) => { ... })` найти строку `if (open) generalError.value = null;` и сразу после неё добавить:

```ts
        if (open) requisitesRequired.value = false;
```

- [ ] **Step 4: Ветка гейта в persist() catch**

В `persist()` в блоке `catch`, найти `if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {` и **перед** этим `if` вставить новую ветку (она станет первой):

```ts
        if (err.response?.status === 422 && err.response.data?.error === 'requisites_required') {
            requisitesRequired.value = true;
        } else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
```

(То есть существующий `if (err.response?.status === 409 ...` превращается в `} else if (err.response?.status === 409 ...`. Остальные `else if`/`else` ниже — без изменений. Тип `data` нужно расширить: в строке объявления `const err = e as {...}` поле `data?: { error?: string; errors?: ... }` уже включает `error?: string` — проверить, что `error` присутствует.)

- [ ] **Step 5: Хендлер перехода к реквизитам**

Перед `function close() {` добавить:

```ts
function goToRequisites(): void {
    close();
    router.push({ path: '/settings', query: { tab: 'requisites' } });
}
```

- [ ] **Step 6: Alert гейта в шаблоне**

В шаблоне найти блок `<v-alert v-if="generalError" ...>` и **перед** ним вставить:

```html
                <v-alert
                    v-if="requisitesRequired"
                    type="warning"
                    variant="tonal"
                    density="compact"
                    class="mt-3"
                    data-testid="requisites-gate-alert"
                >
                    Сначала заполните реквизиты компании — без них нельзя создать первый проект.
                    <div class="mt-2">
                        <v-btn
                            size="small"
                            color="primary"
                            variant="flat"
                            data-testid="requisites-gate-btn"
                            @click="goToRequisites"
                        >
                            Заполнить реквизиты
                        </v-btn>
                    </div>
                </v-alert>
```

- [ ] **Step 7: Type-check + eslint**

Run: `npm --prefix app run type-check`
Expected: exit 0.
Run: `npx --prefix app eslint resources/js/views/projects/NewProjectDialog.vue`
Expected: 0 ошибок.

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/views/projects/NewProjectDialog.vue
LEFTHOOK=0 git commit -m "feat(G1/SP3b): UX гейта — alert requisites_required + переход к реквизитам

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```

---

## Task 5: Сборка + статическая проверка целиком

**Files:** (нет правок — только проверки)

- [ ] **Step 1: Полный type-check**

Run: `npm --prefix app run type-check`
Expected: exit 0.

- [ ] **Step 2: Eslint по изменённым файлам**

Run (из каталога `app`):
`npx eslint resources/js/api/requisites.ts resources/js/views/settings/RequisitesTab.vue resources/js/views/SettingsView.vue resources/js/views/projects/NewProjectDialog.vue`
Expected: 0 ошибок (известный NBSP-хвост `DashboardPageHead.vue` не в нашем списке — игнор).

- [ ] **Step 3: Сборка ассетов**

Run: `npm --prefix app run build`
Expected: успешная сборка (vite-dev не запущен; backend отдаёт собранные ассеты, поэтому пересборка обязательна перед Playwright).

---

## Task 6: Живой Playwright (обязательно, по требованию владельца)

**Предусловие:** backend поднят (`php artisan serve` на `127.0.0.1:8000`), ассеты пересобраны (Task 5 Step 3). DaData на dev выключена → lookup-inn отвечает «не найдено» (это ожидаемо, тестируем именно этот путь). Гейт срабатывает только у тенанта с 0 проектов — поэтому регистрируем свежего клиента (SP1).

- [ ] **Step 1: Регистрация свежего клиента**

Через Playwright MCP:
1. `browser_navigate` → `http://127.0.0.1:8000/register`.
2. Заполнить форму регистрации (уникальный email, пароль, чекбокс капчи).
3. Submit → перейти на `/confirm-email`, прочитать dev-код (показывается на экране), ввести, подтвердить.
Expected: попадаем на `/dashboard` (баланс свежего клиента ~300₽).

- [ ] **Step 2: Гейт при создании первого проекта**

1. `browser_navigate` → `/projects`.
2. Открыть «Новый проект», заполнить минимум (домен, название, регион/Вся РФ), нажать «Создать».
Expected: появился `[data-testid="requisites-gate-alert"]` с текстом про реквизиты (НЕ 201).

- [ ] **Step 3: Переход к реквизитам**

1. Кликнуть `[data-testid="requisites-gate-btn"]`.
Expected: URL `/settings?tab=requisites`, видна вкладка с `[data-testid="requisites-subject-type"]`.

- [ ] **Step 4: Заполнить и сохранить (Физлицо — кратчайший валидный путь)**

1. Выбрать «Физлицо» в `[data-testid="requisites-subject-type"]`.
2. Заполнить `[data-testid="requisites-contact-name"]` и `[data-testid="requisites-contact-phone"]` (валидный телефон, напр. `79161234567`).
3. Кликнуть `[data-testid="requisites-save-btn"]`.
Expected: появился `[data-testid="requisites-save-success"]` «Реквизиты сохранены».

- [ ] **Step 5: Повторное создание проекта проходит**

1. `browser_navigate` → `/projects`, открыть «Новый проект», заполнить, «Создать».
Expected: проект создан (диалог закрылся, проект виден в списке; в network — 201).

- [ ] **Step 6: Валидация ИНН для ИП (негатив)**

1. Вернуться на `/settings?tab=requisites`.
2. Выбрать «ИП», ввести заведомо неверный ИНН (напр. `1234567890`), заполнить контакт, «Сохранить».
Expected: видна ошибка поля ИНН (контрольная цифра) — `[data-testid="requisites-inn"]` в состоянии error с серверным сообщением.

- [ ] **Step 7: Зафиксировать результат**

Сделать `browser_take_screenshot` ключевых экранов (гейт, форма с успехом). Отчёт о прогоне — в ответе владельцу. Если все шаги зелёные — SP3b проверен.

---

## Self-Review (выполнено автором плана)

**1. Spec coverage:**
- §3.1 api-обёртка → Task 1 ✓
- §3.2 RequisitesTab (форма, lookup, валидация, prefill) → Task 2 ✓
- §3.3 вкладка + deep-link → Task 3 ✓
- §3.4 гейт (alert + кнопка) → Task 4 ✓
- §6 верификация (type-check/eslint/build/Playwright) → Task 5 + Task 6 ✓
- §7 YAGNI (без банк-полей, без авто-повтора, без правок бэкенда) → соблюдено в коде задач ✓

**2. Placeholder scan:** плейсхолдеров нет — весь код приведён целиком.

**3. Type consistency:** `Requisites` / `InnLookupResult` определены в Task 1 и используются в Task 2 теми же именами; `getRequisites`/`updateRequisites`/`lookupInn` — единые сигнатуры; `requisitesRequired` / `goToRequisites` — единые имена в Task 4. data-testid в Task 2/4 совпадают с селекторами Task 6.
