import { SearchOutlined } from '@ant-design/icons'; import { Button, Checkbox, Input, Modal, Table, message } from 'antd'; import type { TableColumnsType } from 'antd'; import { useEffect, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { AppModal } from '../../components/AppModal/AppModal'; import { getVersionCard, lockVersion } from '../../services/api/consolidations'; import { getCounterpartyCandidates, mapCounterparty } from '../../services/api/osv'; import type { CandidateOut, CandidateScope, ConsolidatedRowOut, RefOut } from '../../types/api'; import { ALL_SCOPE_MIN_QUERY, type CandidateRow, type PairTarget, aliasOwners, buildCandidatesQuery, candidateRows, lockedByName, mapEntryId, mapErrorKind, mapResultText, pairCandidateRows, } from './counterpartyMapping'; const SEARCH_DELAY_MS = 300; // Цвета — из `counterparty-map-modal__*` макета [32-osv-map-counterparty] const TITLE_COLOR = '#1f7a3a'; const RECOMMENDED_BG = '#f5fcf6'; const SECTION_STYLE: React.CSSProperties = { background: '#eefbf1', color: '#15803d', fontSize: 12, fontWeight: 700, padding: '6px 10px' }; const RELATION_BADGE: Record<'gsk' | 'gsz', React.CSSProperties> = { gsk: { borderColor: '#d7e1f2', background: '#f8faff', color: '#5b6888' }, gsz: { borderColor: '#d8c8ff', background: '#f5f0ff', color: '#6941c6' }, }; function RelationBadge({ kind, value }: { kind: 'gsk' | 'gsz'; value: RefOut | null }) { if (!value) return <>—>; return ( {value.name} ); } type Props = { versionId: string; row: ConsolidatedRowOut; onClose: () => void; /** Сопоставление записано: перечитать строки и сводку. */ onMapped: () => void; /** Режим «Встречные записи»: список сразу сужен до участника пары, «Показать все организации» — обычный список. */ pairTarget?: PairTarget; }; /** Модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: кандидаты, поиск и «Связать». */ export function CounterpartyMapModal({ versionId, row, onClose, onMapped, pairTarget }: Props) { const [q, setQ] = useState(''); const [scope, setScope] = useState('group'); const [showAll, setShowAll] = useState(false); const pairOnly = !!pairTarget && !showAll; const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [submittingId, setSubmittingId] = useState(null); // confirm через хук, а не статический Modal.confirm: в приложении нет antd (arm-web#7) const [modal, contextHolder] = Modal.useModal(); const requestRef = useRef(0); const entryId = mapEntryId(row); const sourceName = row.counterparty_name?.trim() || '—'; // участник пары ищется по имени в группе: в первые 100 кандидатов без поиска он может не попасть const query = pairOnly && pairTarget ? buildCandidatesQuery(entryId, 'group', pairTarget.name) : buildCandidatesQuery(entryId, scope, q); useEffect(() => { const requestId = ++requestRef.current; if (query === null) { setCandidates([]); setLoading(false); return; } setLoading(true); // поиск — после паузы в наборе, ответ устаревшего запроса игнорируется const timer = setTimeout(() => { getCounterpartyCandidates(versionId, query) .then((list) => { if (requestId !== requestRef.current) return; setCandidates(list); setError(null); }) .catch((e: unknown) => { if (requestId !== requestRef.current) return; setCandidates([]); setError(e instanceof ApiError ? e.message : 'Не удалось получить список организаций'); }) .finally(() => { if (requestId === requestRef.current) setLoading(false); }); }, SEARCH_DELAY_MS); return () => clearTimeout(timer); }, [versionId, query]); const lockedByOther = async (e: unknown) => { let name = lockedByName(e); if (!name) name = await getVersionCard(versionId).then( (c) => c.lock?.locked_by_name ?? null, () => null, ); message.error(`Версию держит ${name ?? 'другой пользователь'}: правки недоступны, пока блокировка не снята`); }; /** «Связать»: на 409 спрашивает о переносе алиаса / взятии в работу и повторяет запрос. */ const link = async (c: CandidateOut, moveAlias: boolean, lockTaken: boolean): Promise => { setSubmittingId(c.id); try { const out = await mapCounterparty(versionId, entryId, { legal_entity_id: c.id, move_alias: moveAlias }); message.success(mapResultText(out, c.name)); onMapped(); onClose(); } catch (e: unknown) { const kind = mapErrorKind(e); if (kind === 'ALIAS_TAKEN') { const owners = aliasOwners(e) .map((o) => o.name) .join(', '); const confirmed = await modal.confirm({ title: 'Имя уже закреплено за другой организацией', content: `«${sourceName}» — алиас ${owners || 'другой организации'}. Перенести алиас на ${c.name}?`, okText: 'Перенести', cancelText: 'Отмена', }); if (confirmed) await link(c, true, lockTaken); } else if (kind === 'LOCK_REQUIRED' && !lockTaken) { const confirmed = await modal.confirm({ title: 'Взять версию в работу?', content: 'Сопоставление — правка версии, для неё нужна ваша блокировка. Пока она у вас, другие пользователи не смогут править версию.', okText: 'Взять в работу', cancelText: 'Отмена', }); if (!confirmed) return; try { await lockVersion(versionId); } catch (le: unknown) { if (mapErrorKind(le) === 'LOCKED_BY_OTHER') await lockedByOther(le); else message.error(le instanceof ApiError ? le.message : 'Не удалось взять версию в работу'); return; } await link(c, moveAlias, true); } else if (kind === 'LOCKED_BY_OTHER') { await lockedByOther(e); } else if (kind === 'VERSION_NOT_EDITABLE') { message.error(`Сопоставление недоступно: ${(e as ApiError).message}`); } else { message.error(e instanceof ApiError ? e.message : 'Не удалось связать организацию'); } } finally { setSubmittingId(null); } }; // Строка-заголовок «Рекомендуемые» растягивается на все колонки, у остальных колонок она скрыта const sectionSpan = (r: CandidateRow, first = false): React.TdHTMLAttributes => r.kind === 'section' ? { colSpan: first ? 6 : 0, style: SECTION_STYLE } : { style: r.recommended ? { background: RECOMMENDED_BG, boxShadow: first ? 'inset 3px 0 #63b77c' : undefined } : undefined }; const cell = (render: (c: CandidateOut) => React.ReactNode) => (_: unknown, r: CandidateRow) => r.kind === 'section' ? r.label : render(r.candidate); const columns: TableColumnsType = [ { key: 'inn', title: 'ИНН', width: 132, onCell: (r) => sectionSpan(r, true), render: cell((c) => c.inn) }, { key: 'name', title: 'Организация', onCell: (r) => sectionSpan(r), render: cell((c) => {c.name}) }, { key: 'company_group', title: 'ГК', width: 150, onCell: (r) => sectionSpan(r), render: cell((c) => c.company_group?.name ?? '—') }, { key: 'gsk', title: 'ГСК', width: 170, onCell: (r) => sectionSpan(r), render: cell((c) => ), }, { key: 'gsz', title: 'ГСЗ', width: 170, onCell: (r) => sectionSpan(r), render: cell((c) => ), }, { key: 'action', width: 110, align: 'center', onCell: (r) => sectionSpan(r), render: cell((c) => // текущая связь строки — «Связано» без действия (пересопоставление, макет [32-osv-map-counterparty]) c.id === row.counterparty_id ? ( Связано ) : ( void link(c, false, false)}> Связать ), ), }, ]; const tooShort = !pairOnly && scope === 'all' && query === null; let emptyText = 'Организации не найдены'; if (pairOnly) emptyText = 'Участник пары не найден среди кандидатов — включите «Показать все организации»'; else if (tooShort) emptyText = `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов`; const rows = pairOnly && pairTarget ? pairCandidateRows(candidates, pairTarget) : candidateRows(candidates); return ( Выбор организации для связи с {sourceName} > }> {contextHolder} {!pairOnly && ( } placeholder='Поиск' maxLength={500} value={q} onChange={(e) => setQ(e.target.value)} /> )} {/* флажки — одной строкой */} {pairTarget && ( setShowAll(e.target.checked)}> Показать все организации )} {!pairOnly && ( setScope(e.target.checked ? 'all' : 'group')}> Искать во всём справочнике (не только в группе) )} {error && {error}} size='small' rowKey='key' loading={loading} columns={columns} dataSource={rows} pagination={false} scroll={{ y: 420 }} locale={{ emptyText }} /> ); }