arm-core/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx

280 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<span
style={{
display: 'inline-flex',
maxWidth: '100%',
padding: '3px 9px',
border: '1px solid',
borderRadius: 999,
fontSize: 12,
fontWeight: 500,
lineHeight: 1.25,
...RELATION_BADGE[kind],
}}>
{value.name}
</span>
);
}
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<CandidateScope>('group');
const [showAll, setShowAll] = useState(false);
const pairOnly = !!pairTarget && !showAll;
const [candidates, setCandidates] = useState<CandidateOut[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submittingId, setSubmittingId] = useState<string | null>(null);
// confirm через хук, а не статический Modal.confirm: в приложении нет antd <App> (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<void> => {
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<HTMLTableCellElement> =>
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<CandidateRow> = [
{ 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) => <strong>{c.name}</strong>) },
{ 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) => <RelationBadge kind='gsk' value={c.gsk} />),
},
{
key: 'gsz',
title: 'ГСЗ',
width: 170,
onCell: (r) => sectionSpan(r),
render: cell((c) => <RelationBadge kind='gsz' value={c.gsz} />),
},
{
key: 'action',
width: 110,
align: 'center',
onCell: (r) => sectionSpan(r),
render: cell((c) =>
// текущая связь строки — «Связано» без действия (пересопоставление, макет [32-osv-map-counterparty])
c.id === row.counterparty_id ? (
<Button size='small' disabled>
Связано
</Button>
) : (
<Button
type='primary'
size='small'
loading={submittingId === c.id}
disabled={submittingId !== null && submittingId !== c.id}
onClick={() => void link(c, false, false)}>
Связать
</Button>
),
),
},
];
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 (
<AppModal
open
width={1040}
footer={null}
onCancel={onClose}
title={
<>
Выбор организации для связи с <span style={{ color: TITLE_COLOR }}>{sourceName}</span>
</>
}>
{contextHolder}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!pairOnly && (
<Input
allowClear
autoFocus
size='large'
prefix={<SearchOutlined style={{ color: '#667085' }} />}
placeholder='Поиск'
maxLength={500}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
)}
{/* флажки — одной строкой */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px 24px' }}>
{pairTarget && (
<Checkbox checked={showAll} onChange={(e) => setShowAll(e.target.checked)}>
Показать все организации
</Checkbox>
)}
{!pairOnly && (
<Checkbox checked={scope === 'all'} onChange={(e) => setScope(e.target.checked ? 'all' : 'group')}>
Искать во всём справочнике (не только в группе)
</Checkbox>
)}
</div>
{error && <div style={{ color: '#b42318' }}>{error}</div>}
<Table<CandidateRow>
size='small'
rowKey='key'
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
scroll={{ y: 420 }}
locale={{ emptyText }}
/>
</div>
</AppModal>
);
}