280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|