동기화
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Checkbox, Form, Input, Modal, Typography, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
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 CodeBadge from '@/components/common/CodeBadge';
|
||||
import AttachmentUpload from '@/components/biz/AttachmentUpload';
|
||||
import { complaintApi, ComplaintRow, ComplaintSaveReq, ComplaintSearchParam } from '@/api/complaint';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_ERROR_BG, COLOR_ERROR_BORDER } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const COLUMNS: ColDef<ComplaintRow>[] = [
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'complaintType', headerName: '민원유형', width: 120 },
|
||||
{ field: 'content', headerName: '민원내용', flex: 1 },
|
||||
{ field: 'receivedDate', headerName: '접수일', width: 120 },
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="COMPLAINT_STATUS" value={p.value} />,
|
||||
},
|
||||
{
|
||||
field: 'chargebackTriggered', headerName: '환수', width: 80,
|
||||
cellRenderer: (p: { value: boolean }) =>
|
||||
p.value ? <Text style={{ color: '#E24B4A', fontWeight: 700 }}>환수</Text> : <Text style={{ color: GRAY[400] }}>-</Text>,
|
||||
},
|
||||
{ field: 'chargebackAmount', headerName: '환수금액', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'closedAt', headerName: '처리일', width: 120 },
|
||||
];
|
||||
|
||||
export default function Complaints() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<ComplaintSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [closeTarget, setCloseTarget] = useState<number | null>(null);
|
||||
const [processNote, setProcessNote] = useState('');
|
||||
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['complaints', params],
|
||||
queryFn: () => complaintApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
const chargebackCount = rows.filter((r) => r.chargebackTriggered).length;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as ComplaintSaveReq;
|
||||
try {
|
||||
const created = await complaintApi.create(values);
|
||||
message.success('민원 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['complaints'] });
|
||||
setCreatedId(created.complaintId);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!closeTarget) return;
|
||||
try {
|
||||
await complaintApi.close(closeTarget, processNote);
|
||||
message.success('처리완료');
|
||||
qc.invalidateQueries({ queryKey: ['complaints'] });
|
||||
setCloseTarget(null);
|
||||
setProcessNote('');
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="민원 관리"
|
||||
description="고객 민원 접수, 처리, 환수 연동"
|
||||
extra={
|
||||
<PermissionButton menuCode="COMPLAINTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 민원 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/complaints 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{chargebackCount > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
background: COLOR_ERROR_BG,
|
||||
border: `1px solid ${COLOR_ERROR_BORDER}`,
|
||||
borderRadius: RADIUS.md,
|
||||
marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Text style={{ fontSize: 13, color: '#E24B4A', fontWeight: 700 }}>환수 민원 {chargebackCount}건 — 정산 시 자동 반영</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'complaintType', label: '민원유형', groupCode: 'COMPLAINT_TYPE', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'COMPLAINT_STATUS', span: 6 },
|
||||
{ type: 'dateRange', name: 'receivedDate', label: '접수일', span: 8 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as ComplaintSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<ComplaintRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 120, pinned: 'right',
|
||||
cellRenderer: (p: { data: ComplaintRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="COMPLAINTS" permCode="APPROVE" size="small" type="primary"
|
||||
disabled={p.data.status === 'CLOSED'}
|
||||
onClick={() => { setCloseTarget(p.data.complaintId); setProcessNote(''); }}
|
||||
>
|
||||
처리완료
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="complaintId"
|
||||
getRowStyle={(params) => {
|
||||
if (params.data?.chargebackTriggered) {
|
||||
return { background: '#FEF0F1' };
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 등록 모달 */}
|
||||
<Modal title="민원 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소" width={560}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="complaintType" label="민원유형" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="content" label="민원내용" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="receivedDate" label="접수일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="chargebackTriggered" label="환수 연동" valuePropName="checked">
|
||||
<Checkbox>환수 트리거 (13M 이내 민원)</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 처리완료 모달 */}
|
||||
<Modal
|
||||
title="처리완료 확인"
|
||||
open={closeTarget !== null}
|
||||
onOk={handleClose}
|
||||
onCancel={() => setCloseTarget(null)}
|
||||
okText="처리완료"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="처리 내용" required>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={processNote}
|
||||
onChange={(e) => setProcessNote(e.target.value)}
|
||||
placeholder="처리 내용을 입력하세요"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 첨부파일 (마지막 등록 건) */}
|
||||
{createdId && (
|
||||
<ProCard
|
||||
title="첨부파일"
|
||||
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, marginTop: 16 }}
|
||||
>
|
||||
<AttachmentUpload refType="COMPLAINT" refId={createdId} />
|
||||
</ProCard>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Checkbox, Form, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
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 CodeBadge from '@/components/common/CodeBadge';
|
||||
import { endorsementApi, ContractEndorsementRow, ContractEndorsementSaveReq, EndorsementSearchParam } from '@/api/endorsement';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_WARNING_BG, COLOR_WARNING_BORDER } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const COLUMNS: ColDef<ContractEndorsementRow>[] = [
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 120 },
|
||||
{ field: 'endorsementTypeName', headerName: '변경유형', width: 130 },
|
||||
{ field: 'changeDescription', headerName: '변경내용', flex: 1 },
|
||||
{ field: 'endorsementDate', headerName: '변경일', width: 120 },
|
||||
{
|
||||
field: 'recalculateRequired', headerName: '재계산', width: 90,
|
||||
cellRenderer: (p: { value: boolean }) => p.value
|
||||
? <Tag color="warning" style={{ margin: 0, fontWeight: 700 }}>필요</Tag>
|
||||
: <Tag color="default" style={{ margin: 0 }}>불필요</Tag>,
|
||||
},
|
||||
{
|
||||
field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="ENDORSEMENT_STATUS" value={p.value} />,
|
||||
},
|
||||
{ field: 'processedAt', headerName: '처리일', width: 120 },
|
||||
];
|
||||
|
||||
export default function ContractEndorsements() {
|
||||
const qc = useQueryClient();
|
||||
const [params, setParams] = useState<EndorsementSearchParam>({ pageSize: 100 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['endorsements', params],
|
||||
queryFn: () => endorsementApi.list(params),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
const recalcCount = rows.filter((r) => r.recalculateRequired && r.status !== 'PROCESSED').length;
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields() as ContractEndorsementSaveReq;
|
||||
try {
|
||||
await endorsementApi.create(values);
|
||||
message.success('계약 변경 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['endorsements'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* handled */ }
|
||||
};
|
||||
|
||||
const handleProcess = (id: number) => Modal.confirm({
|
||||
title: '처리 확정',
|
||||
content: '변경사항을 처리 완료로 변경합니다. 재계산이 필요한 경우 정산 배치를 다시 실행하세요.',
|
||||
onOk: async () => {
|
||||
await endorsementApi.process(id);
|
||||
message.success('처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['endorsements'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="계약 변경 관리"
|
||||
description="보험료/수익자/피보험자 변경 이력 및 재계산 트리거"
|
||||
extra={
|
||||
<PermissionButton menuCode="CONTRACT_ENDORSEMENTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 변경 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-endorsements 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{recalcCount > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
background: COLOR_WARNING_BG,
|
||||
border: `1px solid ${COLOR_WARNING_BORDER}`,
|
||||
borderRadius: RADIUS.md,
|
||||
marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Tag color="warning" style={{ fontWeight: 700 }}>재계산 필요 {recalcCount}건</Tag>
|
||||
<Text style={{ fontSize: 13, color: GRAY[700] }}>정산 배치 실행 전 확인이 필요합니다.</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'code', name: 'endorsementType', label: '변경유형', groupCode: 'ENDORSEMENT_TYPE', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', groupCode: 'ENDORSEMENT_STATUS', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setParams({ ...v as EndorsementSearchParam, pageSize: 100 })}
|
||||
onReset={() => setParams({ pageSize: 100 })}
|
||||
/>
|
||||
|
||||
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||
<DataGrid<ContractEndorsementRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLUMNS,
|
||||
{
|
||||
headerName: '액션', width: 120, pinned: 'right',
|
||||
cellRenderer: (p: { data: ContractEndorsementRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="CONTRACT_ENDORSEMENTS" permCode="APPROVE" size="small" type="primary"
|
||||
disabled={p.data.status === 'PROCESSED'}
|
||||
onClick={() => handleProcess(p.data.endorsementId)}
|
||||
>
|
||||
처리
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={600}
|
||||
rowKey="endorsementId"
|
||||
getRowStyle={(params) => {
|
||||
if (params.data?.recalculateRequired && params.data?.status !== 'PROCESSED') {
|
||||
return { background: COLOR_WARNING_BG };
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal title="계약 변경 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="endorsementType" label="변경유형" rules={[{ required: true }]}>
|
||||
<Input placeholder="PREMIUM / INSURED / BENEFICIARY / ETC" />
|
||||
</Form.Item>
|
||||
<Form.Item name="changeDescription" label="변경내용" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endorsementDate" label="변경일" rules={[{ required: true }]}>
|
||||
<Input placeholder="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="recalculateRequired" label="재계산 필요" valuePropName="checked">
|
||||
<Checkbox>재계산 필요</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user