feat: P12(#2) 지급대상건 구성·수정 — 정산월 지급대상 배치 구성/확정 (PL 자율진행)

incar 지급대상건구성/수정/조회 대응. 기존 settle_master 건별 confirm/hold 위에
정산월 단위 지급대상 배치를 구성→포함/제외 편집→확정하는 워크플로우 레이어 추가
(AccountingClose DRAFT/마감 패턴 준용, settle_master 상태는 미변경=결합 최소화).

- DB(V115): payment_batch(배치 마스터, status DRAFT/FINALIZED/CANCELLED, total_count/
  total_payable) + payment_batch_item(건, settle_id FK, payable 스냅샷, included/exclude_reason,
  UNIQUE(batch_id,settle_id)) + 메뉴 PAYMENT_TARGET(GRP_SETTLE) + 권한 READ/CREATE/UPDATE/
  EXECUTE + 공통코드 PAYMENT_BATCH_STATUS.
- core: PaymentBatch/Item VO/Resp/SaveReq/SearchParam/Enum + Mapper. insertFromSettleMaster
  (INSERT-SELECT 자동채움: settle_month·payable>0·status!=PAID) + recalcTotals + updateInclude.
- api: PaymentBatchService create(자동채움+집계)/list/detail/updateItem(DRAFT만,재집계)/
  finalize(포함0건 거부)/cancel/export. Controller /api/payment-batches.
- frontend: PaymentTarget 화면(배치 목록 + 구성 모달 + 상세 Drawer items 포함/제외 토글 +
  합계 + 확정/취소/엑셀) + api 모듈 + App 라우트. export 권한 READ로 정렬.

검증: build+test SUCCESSFUL, Flyway V115(schema v115). 라이브 e2e: 202603 create→39건
자동채움(합 15,438,386.9) → 1건 제외 → 38건 재집계(정확히 −35,079) → finalize → 확정후
수정 400 차단 → cancel. 테스트 배치 정리. 스펙 docs/DOMAIN_GAP_P12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-05 01:10:50 +09:00
parent 934e45d295
commit 00ead64c4c
19 changed files with 1675 additions and 0 deletions
@@ -0,0 +1,577 @@
import { useState } from 'react';
import {
Drawer, Form, Input, Modal, Space, Tag, Typography, message,
} from 'antd';
import { FileExcelOutlined } from '@ant-design/icons';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColDef, ICellRendererParams } 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 {
paymentBatchApi,
PaymentBatchResp,
PaymentBatchItemResp,
PaymentBatchStatus,
} from '@/api/paymentBatch';
import {
GRAY, RADIUS, SHADOW,
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING, COLOR_ERROR,
} from '@/theme/tokens';
const { Text } = Typography;
// ── 상수 ──────────────────────────────────────────────────────────────────
const MENU_CODE = 'PAYMENT_TARGET';
const STATUS_META: Record<
PaymentBatchStatus,
{ label: string; color: string; bg: string; border: string }
> = {
DRAFT: { label: '작성중', color: COLOR_WARNING, bg: '#FFF6E6', border: '#F6D08A' },
FINALIZED: { label: '확정', color: COLOR_SUCCESS, bg: '#E8FAF3', border: '#A3E6C8' },
CANCELLED: { label: '취소', color: COLOR_ERROR, bg: '#FEF0F1', border: '#F9B0B6' },
};
function BatchStatusTag({ status, statusName }: { status: string; statusName?: string }) {
const meta = STATUS_META[status as PaymentBatchStatus];
if (!meta) return <Tag>{statusName ?? status}</Tag>;
return (
<Tag
style={{
color: meta.color,
background: meta.bg,
border: `1px solid ${meta.border}`,
borderRadius: RADIUS.sm,
fontWeight: 600,
fontSize: 12,
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
}}
>
{statusName ?? meta.label}
</Tag>
);
}
// YYYYMM 유효성
function isValidYYYYMM(v: string): boolean {
if (!/^\d{6}$/.test(v)) return false;
const year = parseInt(v.slice(0, 4), 10);
const month = parseInt(v.slice(4, 6), 10);
return year >= 2000 && year <= 2099 && month >= 1 && month <= 12;
}
const fmtMoney = (v: unknown) =>
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
const fmtDt = (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
// ── 배치 목록 컬럼 ────────────────────────────────────────────────────────
const BATCH_COLS: ColDef<PaymentBatchResp>[] = [
{ field: 'batchName', headerName: '배치명', flex: 2, pinned: 'left' },
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{
field: 'status',
headerName: '상태',
width: 110,
cellRenderer: (p: ICellRendererParams<PaymentBatchResp>) =>
p.data ? (
<BatchStatusTag status={p.data.status} statusName={p.data.statusName} />
) : null,
},
{
field: 'totalCount',
headerName: '포함건수',
width: 110,
type: 'numericColumn',
valueFormatter: (p) => `${p.value ?? 0}`,
},
{
field: 'totalPayable',
headerName: '지급합계',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => `${fmtMoney(p.value)}`,
},
{
field: 'finalizedAt',
headerName: '확정일시',
width: 150,
valueFormatter: (p) => fmtDt(p.value as string | undefined),
},
{
field: 'createdAt',
headerName: '생성일시',
width: 150,
valueFormatter: (p) => fmtDt(p.value as string | undefined),
},
];
// ── 배치 아이템 컬럼 (상세 Drawer) ────────────────────────────────────────
function buildItemCols(
isDraft: boolean,
onToggleIncluded: (item: PaymentBatchItemResp) => void,
): ColDef<PaymentBatchItemResp>[] {
return [
{ field: 'agentName', headerName: '설계사', width: 130, pinned: 'left' },
{
field: 'payableAmount',
headerName: '지급액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => `${fmtMoney(p.value)}`,
},
{
field: 'included',
headerName: '포함',
width: 80,
cellRenderer: (p: ICellRendererParams<PaymentBatchItemResp>) => {
if (!p.data) return null;
const item = p.data;
if (isDraft) {
return (
<input
type="checkbox"
checked={item.included}
style={{ cursor: 'pointer', width: 16, height: 16 }}
onChange={() => onToggleIncluded(item)}
/>
);
}
return (
<input
type="checkbox"
checked={item.included}
disabled
style={{ width: 16, height: 16 }}
/>
);
},
},
{
field: 'excludeReason',
headerName: '제외사유',
flex: 2,
valueFormatter: (p) => (p.value as string | undefined) ?? '',
},
];
}
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────
export default function PaymentTarget() {
const qc = useQueryClient();
// 검색 파라미터
const [searchParams, setSearchParams] = useState<{
settleMonth?: string;
status?: string;
}>({});
// 상세 Drawer
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// 구성 모달
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createForm] = Form.useForm();
// 제외사유 편집 모달
const [reasonModal, setReasonModal] = useState<{
open: boolean;
item?: PaymentBatchItemResp;
pendingIncluded?: boolean;
}>({ open: false });
const [reasonForm] = Form.useForm();
// ── 목록 조회 ────────────────────────────────────────
const { data: listData, isLoading: listLoading } = useQuery({
queryKey: ['payment-batches', searchParams],
queryFn: () => paymentBatchApi.list({ ...searchParams, pageSize: 200 }),
retry: false,
});
// ── 상세 조회 ────────────────────────────────────────
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['payment-batch', selectedBatchId],
queryFn: () => paymentBatchApi.detail(selectedBatchId!),
enabled: !!selectedBatchId,
retry: false,
});
// ── 생성 뮤테이션 ────────────────────────────────────
const createMutation = useMutation({
mutationFn: paymentBatchApi.create,
onSuccess: (newId) => {
message.success('배치가 생성되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batches'] });
setCreateModalOpen(false);
createForm.resetFields();
setSelectedBatchId(newId);
setDrawerOpen(true);
},
});
// ── 아이템 업데이트 뮤테이션 ─────────────────────────
const updateItemMutation = useMutation({
mutationFn: ({ itemId, included, excludeReason }: {
itemId: number;
included: boolean;
excludeReason?: string;
}) => paymentBatchApi.updateItem(selectedBatchId!, itemId, { included, excludeReason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 확정 뮤테이션 ────────────────────────────────────
const finalizeMutation = useMutation({
mutationFn: () => paymentBatchApi.finalize(selectedBatchId!),
onSuccess: () => {
message.success('배치가 확정되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 취소 뮤테이션 ────────────────────────────────────
const cancelMutation = useMutation({
mutationFn: () => paymentBatchApi.cancel(selectedBatchId!),
onSuccess: () => {
message.success('배치가 취소되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 엑셀 다운로드 ────────────────────────────────────
const handleExport = async () => {
if (!selectedBatchId) return;
try {
const res = await paymentBatchApi.export(selectedBatchId);
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
const a = document.createElement('a');
a.href = url;
a.download = `지급대상_${detail?.settleMonth ?? ''}_${dayjs().format('YYYYMMDD')}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
/* request.ts 에서 처리 */
}
};
// ── 포함 체크박스 토글 핸들러 ────────────────────────
const handleToggleIncluded = (item: PaymentBatchItemResp) => {
// 포함 → 제외 전환 시 사유 입력 모달 표시
if (item.included) {
reasonForm.setFieldsValue({ excludeReason: item.excludeReason ?? '' });
setReasonModal({ open: true, item, pendingIncluded: false });
} else {
// 제외 → 포함 전환은 사유 없이 바로 처리
updateItemMutation.mutate({
itemId: item.itemId,
included: true,
excludeReason: undefined,
});
}
};
const handleReasonSubmit = async () => {
if (!reasonModal.item) return;
const values = await reasonForm.validateFields();
updateItemMutation.mutate({
itemId: reasonModal.item.itemId,
included: false,
excludeReason: values.excludeReason || undefined,
});
setReasonModal({ open: false });
reasonForm.resetFields();
};
const handleCreateSubmit = async () => {
const values = await createForm.validateFields();
createMutation.mutate({
settleMonth: values.settleMonth,
batchName: values.batchName,
});
};
const rows = listData?.list ?? [];
const isDraft = detail?.status === 'DRAFT';
// 합계행
const batchPinnedBottom =
rows.length > 0
? ({
batchName: '합계',
totalCount: rows.reduce((s, r) => s + (r.totalCount ?? 0), 0),
totalPayable: rows.reduce((s, r) => s + (r.totalPayable ?? 0), 0),
} as Partial<PaymentBatchResp>)
: undefined;
const items = detail?.items ?? [];
const itemPinnedBottom =
items.length > 0
? ({
agentName: '합계',
payableAmount: items
.filter((i) => i.included)
.reduce((s, i) => s + (i.payableAmount ?? 0), 0),
} as Partial<PaymentBatchItemResp>)
: undefined;
const itemCols = buildItemCols(isDraft, handleToggleIncluded);
const cardStyle = {
background: '#fff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
};
return (
<PageContainer
title="지급대상 관리"
description="정산월 지급대상 배치 구성 · 편집 · 확정"
>
{/* ── 검색 폼 ─────────────────────────────────── */}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{
type: 'code',
name: 'status',
label: '상태',
span: 6,
groupCode: 'PAYMENT_BATCH_STATUS',
},
]}
onSearch={(v) => setSearchParams(v as { settleMonth?: string; status?: string })}
onReset={() => setSearchParams({})}
/>
{/* ── 배치 목록 그리드 ─────────────────────────── */}
<ProCard
style={cardStyle}
extra={
<PermissionButton
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={() => {
createForm.resetFields();
setCreateModalOpen(true);
}}
>
+
</PermissionButton>
}
title={
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 15 }}>
</Text>
}
>
<DataGrid<PaymentBatchResp>
rows={rows}
columns={BATCH_COLS}
loading={listLoading}
height={400}
rowKey="batchId"
pinnedBottomRow={batchPinnedBottom}
onSelectionChanged={(e) => {
const sel = e.api.getSelectedRows();
if (sel.length > 0) {
setSelectedBatchId(sel[0].batchId);
setDrawerOpen(true);
}
}}
/>
</ProCard>
{/* ── 상세 Drawer ──────────────────────────────── */}
<Drawer
title={
detail
? `${detail.settleMonth}${detail.batchName}`
: '배치 상세'
}
width={760}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<Space>
<PermissionButton
menuCode={MENU_CODE}
permCode="READ"
icon={<FileExcelOutlined />}
onClick={handleExport}
disabled={!detail}
>
</PermissionButton>
<PermissionButton
menuCode={MENU_CODE}
permCode="UPDATE"
danger
disabled={!detail || detail.status === 'CANCELLED'}
loading={cancelMutation.isPending}
onClick={() => {
Modal.confirm({
title: '배치를 취소하시겠습니까?',
okText: '취소 처리',
okType: 'danger',
cancelText: '닫기',
onOk: () => cancelMutation.mutateAsync(),
});
}}
>
</PermissionButton>
<PermissionButton
menuCode={MENU_CODE}
permCode="EXECUTE"
type="primary"
disabled={!detail || !isDraft}
loading={finalizeMutation.isPending}
onClick={() => {
Modal.confirm({
title: '배치를 확정하시겠습니까?',
content: '확정 후에는 포함 항목을 수정할 수 없습니다.',
okText: '확정',
cancelText: '취소',
onOk: () => finalizeMutation.mutateAsync(),
});
}}
>
</PermissionButton>
</Space>
}
>
{/* 배치 헤더 요약 */}
{detail && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
background: GRAY[50],
borderRadius: RADIUS.md,
border: `1px solid ${GRAY[100]}`,
}}
>
<HeaderStat label="상태" value={<BatchStatusTag status={detail.status} statusName={detail.statusName} />} />
<HeaderStat label="포함건수" value={`${detail.totalCount.toLocaleString()}`} />
<HeaderStat label="지급합계" value={`${fmtMoney(detail.totalPayable)}`} />
</div>
)}
{/* 아이템 그리드 */}
{detailLoading ? (
<Text style={{ color: GRAY[400] }}> ...</Text>
) : (
<DataGrid<PaymentBatchItemResp>
rows={items}
columns={itemCols}
loading={detailLoading}
height={500}
rowKey="itemId"
pinnedBottomRow={itemPinnedBottom}
/>
)}
{/* DRAFT 안내 */}
{isDraft && (
<Text
style={{
display: 'block',
marginTop: 8,
fontSize: 12,
color: GRAY[500],
}}
>
/ .
</Text>
)}
</Drawer>
{/* ── 배치 구성 모달 ────────────────────────────── */}
<Modal
title="지급대상 배치 구성"
open={createModalOpen}
onOk={handleCreateSubmit}
onCancel={() => { setCreateModalOpen(false); createForm.resetFields(); }}
okText="구성"
cancelText="취소"
confirmLoading={createMutation.isPending}
destroyOnClose
>
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="settleMonth"
label="정산월"
rules={[
{ required: true, message: '정산월을 입력하세요' },
{
validator: (_, v) =>
!v || isValidYYYYMM(v)
? Promise.resolve()
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
},
]}
>
<Input placeholder="예: 202501" maxLength={6} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="batchName"
label="배치명"
rules={[{ required: true, message: '배치명을 입력하세요' }]}
>
<Input placeholder="예: 2025년 01월 지급대상" maxLength={100} />
</Form.Item>
</Form>
</Modal>
{/* ── 제외사유 입력 모달 ────────────────────────── */}
<Modal
title="제외 처리"
open={reasonModal.open}
onOk={handleReasonSubmit}
onCancel={() => { setReasonModal({ open: false }); reasonForm.resetFields(); }}
okText="제외"
okType="danger"
cancelText="취소"
confirmLoading={updateItemMutation.isPending}
destroyOnClose
>
<div style={{ marginBottom: 12, color: GRAY[600], fontSize: 13 }}>
: <strong>{reasonModal.item?.agentName}</strong>
</div>
<Form form={reasonForm} layout="vertical">
<Form.Item name="excludeReason" label="제외사유">
<Input.TextArea rows={3} placeholder="제외사유를 입력하세요 (선택)" maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
// ── 헤더 요약 아이템 ──────────────────────────────────────────────────────
function HeaderStat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block' }}>{label}</Text>
<Text style={{ fontSize: 14, fontWeight: 600, color: GRAY[800] }}>{value}</Text>
</div>
);
}