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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user