Files
BizCare/frontend/src/pages/care/BranchListPage.tsx
T
2026-07-12 15:55:08 +09:00

789 lines
28 KiB
TypeScript

import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Checkbox,
Form,
Input,
Modal,
Radio,
Select,
Space,
Table,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { PlusOutlined, SearchOutlined, ShopOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import DraggableModal from '../../components/DraggableModal'
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
import BranchFeeFlowModals from './BranchFeeFlowModals'
type BranchRow = {
id: number
name?: string
officeName?: string
userid?: string
phone?: string
email?: string
sido?: string
level?: number
grant?: number
branchGroupId?: number | null
branchGroupName?: string | null
hasBusinessLicense?: boolean
isTax?: number
createdAt?: string
}
type BranchGroup = { id: number; name: string }
type BranchDetail = BranchRow & {
fax?: string
zipcode?: string
address?: string
addressDetail?: string
tel?: string
centerName?: string
isPersonal?: boolean
accountBank?: string
accountNumber?: string
accountName?: string
contactPhone?: string
contactNateon?: string
acceptPhone?: number
acceptNateon?: number
memo?: string
adminMemo?: string
licenseNumber?: string
licenseType?: string
licenseItem?: string
notifyPhones?: string[]
permissionOrderDateUpdate?: boolean
permissionCalculate?: boolean
permissionConfig?: boolean
permissionExcelDownload?: boolean
permissionChart?: boolean
}
const PAGE_SIZE = 50
const BANKS = ['국민은행', '우리은행', '신한은행', '하나은행', '기업은행', '농협', 'SC제일', '카카오뱅크', '기타']
function levelLabel(l?: number) {
if (l === 5) return <span style={{ color: '#04A600' }}>센터</span>
if (l === 9) return <span style={{ color: '#0F6DC4' }}>관리자</span>
if (l === 10) return <span style={{ color: '#cc0000' }}>최고관리자</span>
return <span>영업점</span>
}
function EditBranchFormBody({
groups,
centers,
}: {
groups: BranchGroup[]
centers: { id: number; name: string }[]
}) {
const isPersonal = Form.useWatch('isPersonal')
const level = Form.useWatch('level')
return (
<>
<FormSection title="기본 정보">
<FormGrid cols={1}>
<FormField label="유형" full>
<Form.Item name="isPersonal" noStyle>
<Radio.Group
className="of-antd-choice-row"
style={{ padding: 0, border: 'none' }}
options={[
{ value: true, label: '개인' },
{ value: false, label: '사업자 (일반/법인)' },
]}
/>
</Form.Item>
</FormField>
</FormGrid>
<FormGrid>
<FormField label="영업점 그룹">
<Form.Item name="branchGroupId" noStyle>
<Select allowClear placeholder="영업점 그룹 선택" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
</Form.Item>
</FormField>
<FormField label="수수료 유지">
<Form.Item name="keepFee" valuePropName="checked" noStyle>
<Checkbox>그룹 변경 시 수수료 유지</Checkbox>
</Form.Item>
</FormField>
<FormField label="레벨">
<Form.Item name="level" noStyle>
<Select options={[
{ value: 1, label: '영업점' },
{ value: 5, label: '센터' },
{ value: 9, label: '관리자' },
{ value: 10, label: '최고관리자' },
]} />
</Form.Item>
</FormField>
<FormField label="입력처">
{level === 5 ? (
<Form.Item name="centerName" noStyle>
<Select allowClear options={centers.map((c) => ({ value: c.name, label: c.name }))} />
</Form.Item>
) : (
<span className="of-muted">센터 레벨에서 선택</span>
)}
</FormField>
{level === 9 && (
<FormField label="권한" full>
<Space wrap size={[8, 4]}>
<Form.Item name="permissionOrderDateUpdate" valuePropName="checked" noStyle><Checkbox>접수일자 변경</Checkbox></Form.Item>
<Form.Item name="permissionCalculate" valuePropName="checked" noStyle><Checkbox>정산</Checkbox></Form.Item>
<Form.Item name="permissionConfig" valuePropName="checked" noStyle><Checkbox>환경설정</Checkbox></Form.Item>
<Form.Item name="permissionExcelDownload" valuePropName="checked" noStyle><Checkbox>엑셀 다운로드</Checkbox></Form.Item>
<Form.Item name="permissionChart" valuePropName="checked" noStyle><Checkbox>통계</Checkbox></Form.Item>
</Space>
</FormField>
)}
<FormField label="이름*">
<Form.Item name="name" noStyle rules={[{ required: true }]}><Input /></Form.Item>
</FormField>
<FormField label="영업점명*">
<Form.Item name="officeName" noStyle rules={[{ required: true }]}><Input /></Form.Item>
</FormField>
<FormField label="아이디">
<Form.Item name="userid" noStyle><Input disabled /></Form.Item>
</FormField>
<FormField label="패스워드">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="password" noStyle style={{ flex: 1, margin: 0 }}>
<Input.Password placeholder="변경 시 입력" />
</Form.Item>
<Form.Item name="passwordConfirmation" noStyle style={{ flex: 1, margin: 0 }}>
<Input.Password placeholder="확인" />
</Form.Item>
</div>
</FormField>
<FormField label="이메일" full>
<Form.Item name="email" noStyle><Input /></Form.Item>
</FormField>
<FormField label="휴대폰">
<Form.Item name="phone" noStyle><Input placeholder="010-0000-0000" /></Form.Item>
</FormField>
<FormField label="연락처 / 팩스">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="tel" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="전화" /></Form.Item>
<Form.Item name="fax" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="팩스" /></Form.Item>
</div>
</FormField>
<FormField label="우편번호">
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
</FormField>
<FormField label="주소">
<Form.Item name="address" noStyle><Input /></Form.Item>
</FormField>
<FormField label="상세주소" full>
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
</FormField>
{isPersonal === false && (
<>
<FormField label="사업자등록번호">
<Form.Item name="licenseNumber" noStyle><Input /></Form.Item>
</FormField>
<FormField label="업태 / 업종">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="licenseType" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업태" /></Form.Item>
<Form.Item name="licenseItem" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업종" /></Form.Item>
</div>
</FormField>
</>
)}
</FormGrid>
</FormSection>
<FormSection title="결제 정보">
<FormGrid>
<FormField label="은행">
<Form.Item name="accountBank" noStyle>
<Select allowClear options={BANKS.map((b) => ({ value: b, label: b }))} />
</Form.Item>
</FormField>
<FormField label="계좌번호">
<Form.Item name="accountNumber" noStyle><Input /></Form.Item>
</FormField>
<FormField label="예금주">
<div style={{ display: 'flex', gap: 8, width: '100%', alignItems: 'center' }}>
<Form.Item name="accountName" noStyle style={{ flex: 1, margin: 0 }}><Input /></Form.Item>
<Form.Item name="accountNameIsName" valuePropName="checked" noStyle>
<Checkbox>정보동일</Checkbox>
</Form.Item>
</div>
</FormField>
<FormField label="세금계산서">
<Form.Item name="isTax" noStyle>
<Select options={[
{ value: 1, label: '세금계산서발행' },
{ value: 0, label: '종합소득세공제' },
]} />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="업무진행 상황 수신정보">
<FormGrid>
<FormField label="수신설정" full>
<Space wrap>
<Form.Item name="acceptPhone" valuePropName="checked" noStyle><Checkbox>핸드폰 문자 받음</Checkbox></Form.Item>
<Form.Item name="acceptNateon" valuePropName="checked" noStyle><Checkbox>네이트온 쪽지 받음</Checkbox></Form.Item>
</Space>
</FormField>
<FormField label="휴대폰">
<Form.Item name="contactPhone" noStyle><Input /></Form.Item>
</FormField>
<FormField label="네이트온 아이디">
<Form.Item name="contactNateon" noStyle><Input /></Form.Item>
</FormField>
<FormField label="정책 수신 휴대폰" full>
<Form.List name="notifyPhones">
{(fields) => (
<Space wrap>
{fields.map((field) => (
<Form.Item {...field} key={field.key} noStyle>
<Input style={{ width: 140 }} placeholder={`휴대폰 ${field.name + 1}`} />
</Form.Item>
))}
</Space>
)}
</Form.List>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="메모">
<FormGrid cols={1}>
<FormField label="메모" full>
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
</FormField>
<FormField label="관리자 메모" full>
<Form.Item name="adminMemo" noStyle><Input.TextArea rows={2} /></Form.Item>
</FormField>
</FormGrid>
</FormSection>
</>
)
}
export default function BranchListPage() {
const qc = useQueryClient()
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [feeOpen, setFeeOpen] = useState(false)
const [feeBranch, setFeeBranch] = useState<BranchRow | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [page, setPage] = useState(0)
const [draft, setDraft] = useState({ fGroup: '', fSido: '', fUserid: '', fOffice: '', fName: '' })
const [applied, setApplied] = useState(draft)
const { data: rows = [], isLoading } = useQuery({
queryKey: ['homes-branches'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<BranchRow[]>>(`${apiPrefix()}/homes/branches`)
return unwrap(data) || []
},
})
const { data: groups = [] } = useQuery({
queryKey: ['homes-branch-groups'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<BranchGroup[]>>(`${apiPrefix()}/homes/branch-groups`)
return unwrap(data) || []
},
})
const { data: centers = [] } = useQuery({
queryKey: ['homes-branch-centers'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ id: number; name: string }[]>>(`${apiPrefix()}/homes/branch-centers`)
return unwrap(data) || []
},
})
const sidoOptions = useMemo(() => {
const set = new Set<string>()
rows.forEach((b) => { if (b.sido) set.add(b.sido) })
return Array.from(set).sort()
}, [rows])
const filtered = useMemo(() => {
const inc = (v: string | null | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
return rows.filter((b) =>
(!applied.fGroup || String(b.branchGroupId ?? '') === applied.fGroup)
&& (!applied.fSido || b.sido === applied.fSido)
&& (!applied.fUserid || inc(b.userid, applied.fUserid))
&& (!applied.fOffice || inc(b.officeName, applied.fOffice))
&& (!applied.fName || inc(b.name, applied.fName)),
)
}, [rows, applied])
const pageRows = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const grantMut = useMutation({
mutationFn: async ({ id, g }: { id: number; g: number }) => {
const { data } = await client.post(`${apiPrefix()}/homes/branches/${id}/grant/${g}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['homes-branches'] })
message.success('승인 상태를 변경했습니다.')
},
onError: (e: Error) => message.error(e.message),
})
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const params = new URLSearchParams({ level: String(values.level ?? 1) })
if (values.branchGroupId) params.set('branchGroupId', String(values.branchGroupId))
const { data } = await client.post(
`${apiPrefix()}/homes/branches?${params.toString()}`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('영업점을 등록했습니다.')
setCreateOpen(false)
form.resetFields()
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const updateMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const { data } = await client.put(
`${apiPrefix()}/homes/branches/${editingId}/detail`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('수정했습니다.')
setEditOpen(false)
setEditingId(null)
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const deleteMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/branches/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const openEdit = async (row: BranchRow) => {
try {
const { data } = await client.get<ApiResponse<BranchDetail>>(
`${apiPrefix()}/homes/branches/${row.id}/detail`,
)
const detail = unwrap(data)
setEditingId(row.id)
editForm.setFieldsValue({
...detail,
isPersonal: detail.isPersonal !== false,
notifyPhones: [...(detail.notifyPhones || []), '', '', '', '', '', ''].slice(0, 6),
keepFee: false,
password: '',
passwordConfirmation: '',
accountNameIsName: false,
acceptPhone: detail.acceptPhone === 1,
acceptNateon: detail.acceptNateon === 1,
})
setEditOpen(true)
} catch (e) {
message.error((e as Error).message)
}
}
const confirmDelete = (row: BranchRow) => {
Modal.confirm({
title: '영업점 삭제',
content: (
<div>
<p><b>{row.officeName}</b> 영업점을 삭제하시겠습니까?</p>
<p className="muted">관련 접수·정산 자료는 삭제되지 않지만, 영업점 계정 정보는 제거됩니다.</p>
</div>
),
okText: '삭제',
okButtonProps: { danger: true },
cancelText: '취소',
onOk: () => deleteMut.mutateAsync(row.id),
})
}
const columns: ColumnsType<BranchRow> = [
{ title: '그룹', dataIndex: 'branchGroupName', width: 110, ellipsis: true },
{ title: '지역', dataIndex: 'sido', width: 70, align: 'center' },
{ title: '아이디', dataIndex: 'userid', width: 100, ellipsis: true },
{ title: '영업점명', dataIndex: 'officeName', width: 120, ellipsis: true },
{ title: '이름', dataIndex: 'name', width: 90, ellipsis: true },
{
title: '레벨',
dataIndex: 'level',
width: 80,
align: 'center',
render: (v) => levelLabel(v),
},
{ title: '연락처', dataIndex: 'phone', width: 120 },
{
title: '사업자',
dataIndex: 'hasBusinessLicense',
width: 70,
align: 'center',
render: (v) => (v ? '사업자' : '개인'),
},
{
title: '세금계산서',
dataIndex: 'isTax',
width: 110,
align: 'center',
render: (v) => (v === 1 ? '세금계산서발행' : '종합소득세공제'),
},
{
title: '회원가입일',
dataIndex: 'createdAt',
width: 150,
align: 'center',
render: (v?: string) => v?.replace('T', ' ').substring(0, 19),
},
{
title: '수수료',
width: 80,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
setFeeBranch(r)
setFeeOpen(true)
}}
>
수수료
</Button>
),
},
{
title: '승인여부',
width: 80,
align: 'center',
render: (_, r) => (
r.grant === 1
? (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
grantMut.mutate({ id: r.id, g: 0 })
}}
>
승인
</Button>
)
: (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
grantMut.mutate({ id: r.id, g: 1 })
}}
>
거부
</Button>
)
),
},
{
title: '수정',
width: 70,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); void openEdit(r) }}
>
수정
</Button>
),
},
{
title: '삭제',
width: 70,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); confirmDelete(r) }}
>
삭제
</Button>
),
},
]
return (
<div className="stock-page branch-list-page">
<div className="stock-title">
<div>
<h2>
<ShopOutlined className="product-title-icon" />
영업점 목록
</h2>
<p>영업점을 검색·등록하고 승인·수수료·상세정보를 관리합니다.</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ level: 1, isTax: 1 }); setCreateOpen(true) }}>
등록
</Button>
</div>
<Card className="stock-table-card" size="small">
<Space wrap style={{ marginBottom: 12 }} size={8}>
<Select
allowClear
placeholder="그룹 선택"
style={{ width: 140 }}
value={draft.fGroup || undefined}
onChange={(v) => setDraft((d) => ({ ...d, fGroup: v ?? '' }))}
options={groups.map((g) => ({ value: String(g.id), label: g.name }))}
/>
<Select
allowClear
placeholder="지역 선택"
style={{ width: 120 }}
value={draft.fSido || undefined}
onChange={(v) => setDraft((d) => ({ ...d, fSido: v ?? '' }))}
options={sidoOptions.map((s) => ({ value: s, label: s }))}
/>
<Input
placeholder="아이디"
style={{ width: 120 }}
value={draft.fUserid}
onChange={(e) => setDraft((d) => ({ ...d, fUserid: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Input
placeholder="영업점명"
style={{ width: 120 }}
value={draft.fOffice}
onChange={(e) => setDraft((d) => ({ ...d, fOffice: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Input
placeholder="이름"
style={{ width: 100 }}
value={draft.fName}
onChange={(e) => setDraft((d) => ({ ...d, fName: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => { setApplied(draft); setPage(0) }}
>
검색
</Button>
<Typography.Text type="secondary">총 {filtered.length}건</Typography.Text>
</Space>
<Table
size="small"
rowKey="id"
loading={isLoading}
columns={columns}
dataSource={pageRows}
pagination={{
current: page + 1,
pageSize: PAGE_SIZE,
total: filtered.length,
showSizeChanger: false,
onChange: (p) => setPage(p - 1),
}}
onRow={(r) => ({
onClick: () => setSelectedId(r.id),
className: selectedId === r.id ? 'row-selected' : undefined,
})}
scroll={{ x: 1400 }}
locale={{ emptyText: '영업점이 없습니다.' }}
/>
</Card>
<DraggableModal
title="영업점 등록"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => form.submit()}
confirmLoading={createMut.isPending}
okText="등록"
width={860}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
>
<Form
form={form}
layout="vertical"
className="order-form-embedded-inner order-form-page reception-apply-form"
onFinish={(v) => createMut.mutate(v)}
initialValues={{ level: 1, isTax: 1 }}
>
<FormSection title="계정 정보">
<FormGrid>
<FormField label="아이디*">
<Form.Item name="userid" noStyle rules={[{ required: true, message: '아이디를 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="패스워드*">
<Form.Item name="password" noStyle rules={[{ required: true, message: '패스워드를 입력하세요' }]}>
<Input.Password />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="기본 정보">
<FormGrid>
<FormField label="상호(영업점명)*">
<Form.Item name="officeName" noStyle rules={[{ required: true, message: '영업점명을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="대표자명*">
<Form.Item name="name" noStyle rules={[{ required: true, message: '대표자명을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="전화">
<Form.Item name="tel" noStyle><Input /></Form.Item>
</FormField>
<FormField label="휴대폰*">
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
<Input placeholder="010-0000-0000" />
</Form.Item>
</FormField>
<FormField label="이메일*" full>
<Form.Item name="email" noStyle rules={[{ required: true, type: 'email', message: '이메일을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="주소">
<FormGrid>
<FormField label="우편번호">
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
</FormField>
<FormField label="주소">
<Form.Item name="address" noStyle><Input /></Form.Item>
</FormField>
<FormField label="상세주소" full>
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="설정">
<FormGrid>
<FormField label="레벨">
<Form.Item name="level" noStyle>
<Select options={[
{ value: 1, label: '영업점' },
{ value: 5, label: '센터' },
{ value: 9, label: '관리자' },
{ value: 10, label: '최고관리자' },
]} />
</Form.Item>
</FormField>
<FormField label="영업점 그룹">
<Form.Item name="branchGroupId" noStyle>
<Select allowClear placeholder="없음" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
</Form.Item>
</FormField>
<FormField label="세금계산서" full>
<Form.Item name="isTax" noStyle>
<Select options={[
{ value: 1, label: '세금계산서발행' },
{ value: 0, label: '종합소득세공제' },
]} />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
</Form>
</DraggableModal>
<DraggableModal
title="영업점 수정"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => editForm.submit()}
confirmLoading={updateMut.isPending}
okText="수정"
width={920}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
>
<Form
form={editForm}
layout="vertical"
className="order-form-embedded-inner order-form-page reception-apply-form"
onFinish={(values) => {
const phones: string[] = values.notifyPhones || []
updateMut.mutate({
...values,
isPersonal: values.isPersonal,
acceptPhone: values.acceptPhone ? 1 : 0,
acceptNateon: values.acceptNateon ? 1 : 0,
notifyPhones: phones,
})
}}
>
<EditBranchFormBody groups={groups} centers={centers} />
</Form>
</DraggableModal>
<BranchFeeFlowModals
open={feeOpen}
branchId={feeBranch?.id ?? null}
branchName={feeBranch?.officeName ?? ''}
onClose={() => { setFeeOpen(false); setFeeBranch(null) }}
/>
</div>
)
}