51ee03453c
incar CMS 메뉴(환수이월관리/환수계약리스트/위촉중환수) ↔ GA 부재 + 코드 갭 (AggregateStep net<0 시 경고만, 미회수 환수 채권화·차월상계·대손 생애주기 전무) 을 도출해 풀스택 구현. 미회수 환수가 소실되던 재무 누락을 폐쇄. - DB(V111): clawback_receivable(채권 마스터, UNIQUE agent+origin월) + clawback_recovery(상계 회수 이력) + settle_master 3컬럼(clawback_recovered/ clawback_carryover/payable_amount) + 메뉴 CLAWBACK_RECEIVABLE + 권한 + 공통코드. - core: VO/Resp/SaveReq/SearchParam/Enum/Mapper(.java/.xml) 2테이블 + MapStruct. outstandingBalance(origin<settleMonth 자기상계방지) / FIFO / upsertCarryover(멱등). - api: ClawbackReceivableService/Controller(/api/clawback-receivables) — list/detail/ summary/write-off(OUTSTANDING·RECOVERING만, CLEARED/WRITTEN_OFF 전이)/export(@Transactional). - batch: AggregateStep 통합 — net<0 → carryover 채권화, net>0 → FIFO 상계회수, reverse-먼저 방식으로 동일월 재실행 멱등. net_amount 의미 불변(무결성 보존). payable_amount = max(0,net) − recovered. - fix(잠복): SettleMasterMapper.xml upsert에 신규 3컬럼 누락 → INSERT/ON CONFLICT 추가 (배치가 VO에 set해도 DEFAULT 0으로 미영속되던 버그, PL 검증서 발견). - frontend: ClawbackReceivable 화면(AG Grid 합계행 + 회수이력 타임라인 Drawer + 대손/상환 모달) + api 모듈 + App 라우트. 검증(PL 직접): ./gradlew build(test포함) SUCCESSFUL. Flyway V109~V111 운영DB 적용 (schema v111). 라이브 API: GET 목록/summary 200, write-off WRITTEN_OFF 200(한글사유 영속), 종결 재처리·잘못된 전이상태 E411 차단. 배치 이월/상계/멱등/부분상계 4시나리오 실 스키마 롤백 트랜잭션 검증 PASS(CHECK balance>=0 포함). 잔여 데이터 0. 스펙: docs/DOMAIN_GAP_P9_환수채권이월.md. 후속 갭(시상/인정실적 환수 ledger, 지급대상건 구성, 환수 보험사자료 업로드) 문서화. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Drawer, Form, Input, Modal, Select, Space, Tag, Timeline, Typography, message,
|
|
} from 'antd';
|
|
import { ProCard } from '@ant-design/pro-components';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import type { ColDef } from '@ag-grid-community/core';
|
|
import dayjs from 'dayjs';
|
|
import PageContainer from '@/components/common/PageContainer';
|
|
import SearchForm from '@/components/common/SearchForm';
|
|
import DataGrid from '@/components/common/DataGrid';
|
|
import PermissionButton from '@/components/common/PermissionButton';
|
|
import {
|
|
clawbackReceivableApi,
|
|
ClawbackReceivableRow,
|
|
ClawbackReceivableDetail,
|
|
ClawbackSearchParam,
|
|
} from '@/api/clawbackReceivable';
|
|
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens';
|
|
|
|
const { Text } = Typography;
|
|
|
|
const fmt = (v: unknown) =>
|
|
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
OUTSTANDING: COLOR_ERROR,
|
|
RECOVERING: COLOR_WARNING,
|
|
CLEARED: COLOR_SUCCESS,
|
|
WRITTEN_OFF: GRAY[400],
|
|
};
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
OUTSTANDING: '미회수',
|
|
RECOVERING: '회수중',
|
|
CLEARED: '상환완료',
|
|
WRITTEN_OFF: '대손',
|
|
};
|
|
|
|
function StatusChip({ status, statusName }: { status: string; statusName?: string }) {
|
|
const color = STATUS_COLOR[status] ?? GRAY[400];
|
|
const label = statusName ?? STATUS_LABEL[status] ?? status;
|
|
return (
|
|
<Tag
|
|
style={{
|
|
color,
|
|
background: `${color}1A`,
|
|
border: `1px solid ${color}40`,
|
|
borderRadius: RADIUS.sm,
|
|
fontWeight: 600,
|
|
fontSize: 12,
|
|
}}
|
|
>
|
|
{label}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
const GRID_COLS: ColDef<ClawbackReceivableRow>[] = [
|
|
{ field: 'agentName', headerName: '설계사', width: 130, pinned: 'left' },
|
|
{ field: 'originSettleMonth', headerName: '발생월', width: 110 },
|
|
{
|
|
field: 'originalAmount',
|
|
headerName: '발생액',
|
|
flex: 1,
|
|
type: 'numericColumn',
|
|
valueFormatter: (p) => fmt(p.value),
|
|
},
|
|
{
|
|
field: 'recoveredAmount',
|
|
headerName: '회수액',
|
|
flex: 1,
|
|
type: 'numericColumn',
|
|
valueFormatter: (p) => fmt(p.value),
|
|
},
|
|
{
|
|
field: 'balance',
|
|
headerName: '잔액',
|
|
flex: 1,
|
|
type: 'numericColumn',
|
|
valueFormatter: (p) => fmt(p.value),
|
|
},
|
|
{
|
|
field: 'status',
|
|
headerName: '상태',
|
|
width: 120,
|
|
cellRenderer: (p: { data: ClawbackReceivableRow }) =>
|
|
p.data ? <StatusChip status={p.data.status} statusName={p.data.statusName} /> : null,
|
|
},
|
|
{ field: 'lastRecoveryMonth', headerName: '최근회수월', width: 120 },
|
|
];
|
|
|
|
const MENU_CODE = 'CLAWBACK_RECEIVABLE';
|
|
|
|
export default function ClawbackReceivable() {
|
|
const qc = useQueryClient();
|
|
const [params, setParams] = useState<ClawbackSearchParam>({ pageSize: 200 });
|
|
const [selectedRow, setSelectedRow] = useState<ClawbackReceivableRow | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [writeOffOpen, setWriteOffOpen] = useState(false);
|
|
const [writeOffForm] = Form.useForm();
|
|
|
|
// ── 목록 조회 ──────────────────────────────────────
|
|
const { data: listData, isLoading } = useQuery({
|
|
queryKey: ['clawback-receivables', params],
|
|
queryFn: () => clawbackReceivableApi.list(params),
|
|
retry: false,
|
|
});
|
|
|
|
// ── 상세 조회 (행 클릭 시) ─────────────────────────
|
|
const { data: detail, isLoading: detailLoading } = useQuery({
|
|
queryKey: ['clawback-receivable', selectedRow?.receivableId],
|
|
queryFn: () => clawbackReceivableApi.detail(selectedRow!.receivableId),
|
|
enabled: !!selectedRow,
|
|
retry: false,
|
|
});
|
|
|
|
// ── 대손/상환 처리 뮤테이션 ──────────────────────────
|
|
const writeOffMutation = useMutation({
|
|
mutationFn: ({ id, body }: { id: number; body: { status: 'CLEARED' | 'WRITTEN_OFF'; writeoffReason: string } }) =>
|
|
clawbackReceivableApi.writeOff(id, body),
|
|
onSuccess: () => {
|
|
message.success('처리 완료');
|
|
qc.invalidateQueries({ queryKey: ['clawback-receivables'] });
|
|
qc.invalidateQueries({ queryKey: ['clawback-receivable', selectedRow?.receivableId] });
|
|
setWriteOffOpen(false);
|
|
writeOffForm.resetFields();
|
|
},
|
|
});
|
|
|
|
const rows = listData?.list ?? [];
|
|
|
|
// ── 합계행 ─────────────────────────────────────────
|
|
const pinnedBottom =
|
|
rows.length > 0
|
|
? ({
|
|
agentName: '합계',
|
|
originalAmount: rows.reduce((s, r) => s + (r.originalAmount ?? 0), 0),
|
|
recoveredAmount: rows.reduce((s, r) => s + (r.recoveredAmount ?? 0), 0),
|
|
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
|
|
} as Partial<ClawbackReceivableRow>)
|
|
: undefined;
|
|
|
|
const handleRowClick = (row: ClawbackReceivableRow) => {
|
|
setSelectedRow(row);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
const res = await clawbackReceivableApi.export(params);
|
|
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `환수채권_${dayjs().format('YYYYMMDD')}.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
/* request.ts 처리 */
|
|
}
|
|
};
|
|
|
|
const handleWriteOffSubmit = async () => {
|
|
const values = await writeOffForm.validateFields();
|
|
if (!selectedRow) return;
|
|
writeOffMutation.mutate({
|
|
id: selectedRow.receivableId,
|
|
body: { status: values.status, writeoffReason: values.writeoffReason },
|
|
});
|
|
};
|
|
|
|
const canWriteOff =
|
|
selectedRow?.status === 'OUTSTANDING' || selectedRow?.status === 'RECOVERING';
|
|
|
|
const cardStyle = {
|
|
background: '#fff',
|
|
borderRadius: RADIUS.lg,
|
|
border: `1px solid ${GRAY[100]}`,
|
|
boxShadow: SHADOW.sm,
|
|
};
|
|
|
|
return (
|
|
<PageContainer
|
|
title="환수채권관리"
|
|
description="환수채권 이월/미수금 목록 조회 및 대손/상환 처리"
|
|
extra={
|
|
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
|
|
엑셀 다운로드
|
|
</PermissionButton>
|
|
}
|
|
>
|
|
<SearchForm
|
|
conditions={[
|
|
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
|
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'CLAWBACK_RCV_STATUS' },
|
|
{ type: 'month', name: 'originSettleMonth', label: '발생월', span: 6 },
|
|
]}
|
|
onSearch={(v) => setParams({ ...(v as ClawbackSearchParam), pageSize: 200 })}
|
|
onReset={() => setParams({ pageSize: 200 })}
|
|
/>
|
|
|
|
<ProCard style={cardStyle}>
|
|
<DataGrid<ClawbackReceivableRow>
|
|
rows={rows}
|
|
columns={GRID_COLS}
|
|
loading={isLoading}
|
|
height={560}
|
|
rowKey="receivableId"
|
|
pinnedBottomRow={pinnedBottom}
|
|
onSelectionChanged={(e) => {
|
|
const sel = e.api.getSelectedRows();
|
|
if (sel.length > 0) handleRowClick(sel[0]);
|
|
}}
|
|
/>
|
|
</ProCard>
|
|
|
|
{/* ── 상세 Drawer ─────────────────────────────── */}
|
|
<Drawer
|
|
title="환수채권 상세"
|
|
width={520}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
extra={
|
|
<PermissionButton
|
|
menuCode={MENU_CODE}
|
|
permCode="UPDATE"
|
|
type="primary"
|
|
danger
|
|
disabled={!canWriteOff}
|
|
onClick={() => setWriteOffOpen(true)}
|
|
>
|
|
대손/상환 처리
|
|
</PermissionButton>
|
|
}
|
|
>
|
|
{detailLoading || !detail ? (
|
|
<Text style={{ color: GRAY[400] }}>불러오는 중...</Text>
|
|
) : (
|
|
<DrawerContent detail={detail} />
|
|
)}
|
|
</Drawer>
|
|
|
|
{/* ── 대손/상환 처리 모달 ──────────────────────── */}
|
|
<Modal
|
|
title="대손/상환 처리"
|
|
open={writeOffOpen}
|
|
onOk={handleWriteOffSubmit}
|
|
onCancel={() => { setWriteOffOpen(false); writeOffForm.resetFields(); }}
|
|
okText="처리"
|
|
cancelText="취소"
|
|
confirmLoading={writeOffMutation.isPending}
|
|
>
|
|
<Form form={writeOffForm} layout="vertical" style={{ marginTop: 16 }}>
|
|
<Form.Item name="status" label="처리 유형" rules={[{ required: true, message: '처리 유형을 선택하세요' }]}>
|
|
<Select placeholder="선택">
|
|
<Select.Option value="CLEARED">상환완료 (직접 상환)</Select.Option>
|
|
<Select.Option value="WRITTEN_OFF">대손 처리</Select.Option>
|
|
</Select>
|
|
</Form.Item>
|
|
<Form.Item name="writeoffReason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
|
|
<Input.TextArea rows={3} placeholder="처리 사유를 입력하세요" maxLength={300} showCount />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
// ── 상세 내용 컴포넌트 ─────────────────────────────────
|
|
|
|
function DrawerContent({ detail }: { detail: ClawbackReceivableDetail }) {
|
|
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
|
{ label: '설계사', value: detail.agentName },
|
|
{ label: '발생월', value: detail.originSettleMonth },
|
|
{ label: '발생액', value: fmt(detail.originalAmount) },
|
|
{ label: '회수액', value: fmt(detail.recoveredAmount) },
|
|
{ label: '잔액', value: fmt(detail.balance) },
|
|
{ label: '상태', value: <StatusChip status={detail.status} statusName={detail.statusName} /> },
|
|
{ label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' },
|
|
{ label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' },
|
|
];
|
|
|
|
if (detail.writeoffReason) {
|
|
fields.push({ label: '처리사유', value: detail.writeoffReason });
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '100px 1fr',
|
|
gap: '10px 12px',
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
{fields.map(({ label, value }) => (
|
|
<>
|
|
<Text key={`lbl-${label}`} style={{ color: GRAY[500], fontSize: 13 }}>{label}</Text>
|
|
<Text key={`val-${label}`} style={{ color: GRAY[800], fontSize: 13, fontWeight: 500 }}>{value}</Text>
|
|
</>
|
|
))}
|
|
</div>
|
|
|
|
{detail.recoveries.length > 0 && (
|
|
<>
|
|
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 14, display: 'block', marginBottom: 12 }}>
|
|
회수 이력
|
|
</Text>
|
|
<Timeline
|
|
items={detail.recoveries.map((r) => ({
|
|
color: COLOR_PRIMARY,
|
|
children: (
|
|
<Space direction="vertical" size={2}>
|
|
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 13 }}>
|
|
{r.settleMonth} 정산월
|
|
</Text>
|
|
<Text style={{ color: GRAY[500], fontSize: 12 }}>
|
|
회수액: {fmt(r.recoveredAmount)}원
|
|
</Text>
|
|
<Text style={{ color: GRAY[400], fontSize: 11 }}>
|
|
{dayjs(r.createdAt).format('YYYY-MM-DD')}
|
|
</Text>
|
|
</Space>
|
|
),
|
|
}))}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{detail.recoveries.length === 0 && (
|
|
<Text style={{ color: GRAY[400], fontSize: 13 }}>회수 이력이 없습니다.</Text>
|
|
)}
|
|
</>
|
|
);
|
|
}
|