nation 버전 이식
This commit is contained in:
@@ -23,6 +23,8 @@ import ContractAdjustPage from './pages/care/ContractAdjustPage'
|
||||
import ContractBalancePage from './pages/care/ContractBalancePage'
|
||||
import ContractWritePage from './pages/care/ContractWritePage'
|
||||
import ReceptionListPage from './pages/care/ReceptionListPage'
|
||||
import BranchListPage from './pages/care/BranchListPage'
|
||||
import CalculatePage from './pages/care/CalculatePage'
|
||||
import InvoicePublishPage from './pages/care/InvoicePublishPage'
|
||||
import InvoiceMyPage from './pages/care/InvoiceMyPage'
|
||||
import InvoiceLogPage from './pages/care/InvoiceLogPage'
|
||||
@@ -48,6 +50,14 @@ import SettingPreferencePage from './pages/care/SettingPreferencePage'
|
||||
import SettingLevelPage from './pages/care/SettingLevelPage'
|
||||
import SettingPartner1Page from './pages/care/SettingPartner1Page'
|
||||
import SettingPartner2Page from './pages/care/SettingPartner2Page'
|
||||
import SettingIspPage from './pages/care/SettingIspPage'
|
||||
import SettingCenterPage from './pages/care/SettingCenterPage'
|
||||
import SettingIspLinkPage from './pages/care/SettingIspLinkPage'
|
||||
import SettingBoardAdminPage from './pages/care/SettingBoardAdminPage'
|
||||
import SettingFeePage from './pages/care/SettingFeePage'
|
||||
import SettingQuickDeliveryPage from './pages/care/SettingQuickDeliveryPage'
|
||||
import SettingConfigurePage from './pages/care/SettingConfigurePage'
|
||||
import SettingUsageReportPage from './pages/care/SettingUsageReportPage'
|
||||
import CsNoticePage from './pages/care/CsNoticePage'
|
||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||
import BbsPage from './pages/care/BbsPage'
|
||||
@@ -70,6 +80,8 @@ export default function App() {
|
||||
token: {
|
||||
colorPrimary: THEME_PRIMARY[theme] || '#1677ff',
|
||||
colorBgContainer: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorBgLayout: theme === 'light' ? '#f1f5f9' : theme === 'blue' ? '#0a1522' : '#16181e',
|
||||
colorTextLightSolid: '#ffffff',
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.55)' : 'rgba(192, 202, 245, 0.5)',
|
||||
colorBorderSecondary: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.35)' : 'rgba(192, 202, 245, 0.32)',
|
||||
@@ -80,6 +92,9 @@ export default function App() {
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
},
|
||||
Drawer: {
|
||||
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
},
|
||||
Checkbox: {
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.65)' : 'rgba(192, 202, 245, 0.58)',
|
||||
},
|
||||
@@ -114,6 +129,11 @@ export default function App() {
|
||||
|
||||
<Route path="reception/list" element={<ReceptionListPage />} />
|
||||
|
||||
<Route path="branch/list" element={<BranchListPage />} />
|
||||
|
||||
<Route path="calculate/branch" element={<CalculatePage kind="branch" />} />
|
||||
<Route path="calculate/center" element={<CalculatePage kind="center" />} />
|
||||
|
||||
<Route path="invoice/publish" element={<InvoicePublishPage />} />
|
||||
<Route path="invoice/my" element={<InvoiceMyPage />} />
|
||||
<Route path="invoice/log" element={<InvoiceLogPage />} />
|
||||
@@ -145,6 +165,14 @@ export default function App() {
|
||||
<Route path="setting/level" element={<SettingLevelPage />} />
|
||||
<Route path="setting/partner1" element={<SettingPartner1Page />} />
|
||||
<Route path="setting/partner2" element={<SettingPartner2Page />} />
|
||||
<Route path="setting/isp" element={<SettingIspPage />} />
|
||||
<Route path="setting/center" element={<SettingCenterPage />} />
|
||||
<Route path="setting/isp-link" element={<SettingIspLinkPage />} />
|
||||
<Route path="setting/board-admin" element={<SettingBoardAdminPage />} />
|
||||
<Route path="setting/fee" element={<SettingFeePage />} />
|
||||
<Route path="setting/quick-delivery" element={<SettingQuickDeliveryPage />} />
|
||||
<Route path="setting/configure" element={<SettingConfigurePage />} />
|
||||
<Route path="setting/usage" element={<SettingUsageReportPage />} />
|
||||
|
||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
|
||||
[shop, allSections, allowedMenus],
|
||||
)
|
||||
const selectedKey = resolveSelectedKey(location.pathname)
|
||||
const selectedKey = resolveSelectedKey(location.pathname, location.search)
|
||||
|
||||
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, sections])
|
||||
}, [location.pathname, location.search, sections])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.width', String(panelWidth))
|
||||
@@ -220,7 +220,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
key={it.key}
|
||||
to={it.key}
|
||||
title={it.label}
|
||||
className={({ isActive }) => (isActive || selectedKey === it.key ? 'active' : undefined)}
|
||||
className={() => (selectedKey === it.key ? 'active' : undefined)}
|
||||
>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
|
||||
@@ -207,7 +207,7 @@ function DraggableModal({
|
||||
titleActions={titleActions}
|
||||
draggable={draggable}
|
||||
showClose={showClose}
|
||||
onClose={(e) => onCancel?.(e)}
|
||||
onClose={(e) => handleCancel(e)}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
)}
|
||||
@@ -227,37 +227,15 @@ function DraggableModal({
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragShell({
|
||||
function StaticDragWrap({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
onClose,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
onClose?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={false}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragWrap({ title, children }: { title?: ReactNode; children: ReactNode }) {
|
||||
const [open] = useState(true)
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
|
||||
@@ -273,13 +251,61 @@ function StaticDragWrap({ title, children }: { title?: ReactNode; children: Reac
|
||||
}, [shouldSuppressClose])
|
||||
|
||||
return (
|
||||
<StaticDragShell title={title} offset={offset} onTitleMouseDown={onTitleMouseDown}>
|
||||
<StaticDragShell
|
||||
title={title}
|
||||
offset={offset}
|
||||
onTitleMouseDown={onTitleMouseDown}
|
||||
onClose={() => {
|
||||
if (onClose) onClose()
|
||||
else AntModal.destroyAll()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StaticDragShell>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
function StaticDragShell({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
onClose,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
onClose?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={!!onClose}
|
||||
onClose={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose?.()
|
||||
}}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(
|
||||
config: ModalFuncProps = {},
|
||||
closer?: { current: (() => void) | null },
|
||||
): ModalFuncProps {
|
||||
const userRender = config.modalRender
|
||||
const title = config.title
|
||||
return {
|
||||
@@ -290,18 +316,37 @@ function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
className: withModalClass(config.className),
|
||||
rootClassName: ['app-draggable-modal-root', config.rootClassName].filter(Boolean).join(' '),
|
||||
modalRender: (node) => {
|
||||
const wrapped = <StaticDragWrap title={title}>{node}</StaticDragWrap>
|
||||
const wrapped = (
|
||||
<StaticDragWrap
|
||||
title={title}
|
||||
onClose={() => {
|
||||
closer?.current?.()
|
||||
if (!closer?.current) AntModal.destroyAll()
|
||||
}}
|
||||
>
|
||||
{node}
|
||||
</StaticDragWrap>
|
||||
)
|
||||
return userRender ? userRender(wrapped) : wrapped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = (props?: ModalFuncProps) => AntModal.confirm(wrapStatic(props))
|
||||
DraggableModal.info = (props?: ModalFuncProps) => AntModal.info(wrapStatic(props))
|
||||
DraggableModal.success = (props?: ModalFuncProps) => AntModal.success(wrapStatic(props))
|
||||
DraggableModal.error = (props?: ModalFuncProps) => AntModal.error(wrapStatic(props))
|
||||
DraggableModal.warning = (props?: ModalFuncProps) => AntModal.warning(wrapStatic(props))
|
||||
DraggableModal.warn = (props?: ModalFuncProps) => AntModal.warn(wrapStatic(props))
|
||||
function withStaticCloser(factory: (cfg: ModalFuncProps) => ReturnType<typeof AntModal.info>) {
|
||||
return (props?: ModalFuncProps) => {
|
||||
const closer: { current: (() => void) | null } = { current: null }
|
||||
const api = factory(wrapStatic(props, closer))
|
||||
closer.current = () => api.destroy()
|
||||
return api
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = withStaticCloser((cfg) => AntModal.confirm(cfg))
|
||||
DraggableModal.info = withStaticCloser((cfg) => AntModal.info(cfg))
|
||||
DraggableModal.success = withStaticCloser((cfg) => AntModal.success(cfg))
|
||||
DraggableModal.error = withStaticCloser((cfg) => AntModal.error(cfg))
|
||||
DraggableModal.warning = withStaticCloser((cfg) => AntModal.warning(cfg))
|
||||
DraggableModal.warn = withStaticCloser((cfg) => AntModal.warn(cfg))
|
||||
DraggableModal.destroyAll = AntModal.destroyAll
|
||||
DraggableModal.useModal = AntModal.useModal
|
||||
DraggableModal.config = AntModal.config
|
||||
|
||||
@@ -5,6 +5,7 @@ import Modal from './DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
import { statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -16,12 +17,7 @@ type Props = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const GIFT_PLACES = [
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사발송' },
|
||||
{ value: '영업점', label: '영업점발송' },
|
||||
]
|
||||
const GIFT_PLACES = Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))
|
||||
|
||||
export default function ReceptionBulkModal({
|
||||
open,
|
||||
@@ -176,7 +172,9 @@ export default function ReceptionBulkModal({
|
||||
placeholder="진행상황 선택"
|
||||
value={pgStatus}
|
||||
onChange={(v) => setPgStatus(v)}
|
||||
options={options(progressStatuses)}
|
||||
options={statusOptions(
|
||||
Object.fromEntries((progressStatuses.length ? progressStatuses : Object.keys(PROGRESS_STATUS)).map((c) => [c, PROGRESS_STATUS[c] || c])),
|
||||
)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<Checkbox checked={pgNotNotice} onChange={(e) => setPgNotNotice(e.target.checked)}>
|
||||
@@ -236,7 +234,9 @@ export default function ReceptionBulkModal({
|
||||
placeholder="사은품 진행상황 선택"
|
||||
value={gStatus}
|
||||
onChange={(v) => setGStatus(v)}
|
||||
options={options(giftStatuses)}
|
||||
options={statusOptions(
|
||||
Object.fromEntries((giftStatuses.length ? giftStatuses : Object.keys(GIFT_STATUS)).map((c) => [c, GIFT_STATUS[c] || c])),
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품진행메모(LOG)" full>
|
||||
|
||||
@@ -14,6 +14,7 @@ import Modal from './DraggableModal'
|
||||
import PrgSection from './PrgSection'
|
||||
import ReceptionProgressReadOnly from './ReceptionProgressReadOnly'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { progressLabel, giftLabel, statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -117,8 +118,13 @@ export default function ReceptionProgressModal({
|
||||
|
||||
const data = detailQuery.data
|
||||
const reception = data?.reception || data
|
||||
const progressStatuses: string[] = data?.progressStatuses || []
|
||||
const giftStatuses: string[] = data?.giftStatuses || []
|
||||
const progressStatuses: string[] = data?.progressStatuses || Object.keys(PROGRESS_STATUS)
|
||||
const giftStatuses: string[] = data?.giftStatuses || Object.keys(GIFT_STATUS)
|
||||
const progressLabels = (data?.progressStatusLabels || data?.statusOptions || PROGRESS_STATUS) as Record<string, string>
|
||||
const giftLabels = (data?.giftStatusLabels || data?.giftStatusOptions || GIFT_STATUS) as Record<string, string>
|
||||
const allowed: string[] = data?.allowedProgressStatuses || []
|
||||
const progressOpts = statusOptions(progressLabels, allowed.length ? allowed : undefined)
|
||||
const giftOpts = statusOptions(giftLabels)
|
||||
const progressLogs: any[] = data?.progressLogs || data?.logs || []
|
||||
const giftLogs: any[] = data?.giftProgressLogs || []
|
||||
|
||||
@@ -234,7 +240,7 @@ export default function ReceptionProgressModal({
|
||||
}
|
||||
}
|
||||
|
||||
const titleWarn = reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const titleWarn = reception?.progressStatus === 'f11' || reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const title = receptionId
|
||||
? `${titleWarn ? '⚠ 주의 요망 ' : ''}업무 진행 내역 — 접수 #${receptionId}`
|
||||
: '업무 진행 내역'
|
||||
@@ -276,7 +282,7 @@ export default function ReceptionProgressModal({
|
||||
<tr>
|
||||
<th>현재진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<Input readOnly value={reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
<Input readOnly value={progressLabel(reception?.progressStatus) || reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -301,7 +307,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ minWidth: 180 }}
|
||||
value={f.status || undefined}
|
||||
onChange={(v) => set({ status: v || '' })}
|
||||
options={progressStatuses.map((s) => ({ value: s, label: s }))}
|
||||
options={progressOpts.length ? progressOpts : progressStatuses.map((s) => ({ value: s, label: progressLabel(s) || s }))}
|
||||
/>
|
||||
<Button size="small" loading={saveMut.isPending} onClick={() => saveMut.mutate(false)}>
|
||||
적 용
|
||||
@@ -433,7 +439,7 @@ export default function ReceptionProgressModal({
|
||||
<tr>
|
||||
<th>사은품현재상황</th>
|
||||
<td>
|
||||
<Input readOnly value={data.gift?.status || reception?.giftStatus || ''} />
|
||||
<Input readOnly value={giftLabel(data.gift?.status || reception?.giftStatus) || data.gift?.status || reception?.giftStatus || ''} />
|
||||
</td>
|
||||
<th>사은품진행상황</th>
|
||||
<td>
|
||||
@@ -443,7 +449,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftStatus || undefined}
|
||||
onChange={(v) => set({ giftStatus: v || '' })}
|
||||
options={giftStatuses.map((s) => ({ value: s, label: s }))}
|
||||
options={giftOpts.length ? giftOpts : giftStatuses.map((s) => ({ value: s, label: giftLabel(s) || s }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -455,12 +461,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftPlace || undefined}
|
||||
onChange={(v) => set({ giftPlace: v || '' })}
|
||||
options={[
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사' },
|
||||
{ value: '영업점', label: '영업점' },
|
||||
]}
|
||||
options={Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</td>
|
||||
<th>지급요청/완료</th>
|
||||
|
||||
+38
-3
@@ -44,10 +44,25 @@ export function careMenuSections(): MenuSection[] {
|
||||
{ key: '/care/reception/list', label: '접수리스트' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
title: '영업점관리',
|
||||
items: [
|
||||
{ key: '/care/branch/list', label: '영업점목록' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: '정산관리',
|
||||
items: [
|
||||
{ key: '/care/calculate/branch?tab=prepared', label: '가정산 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=waiting', label: '정산대기 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=completed', label: '정산완료 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=transferred', label: '송금(환수)완료 리스트' },
|
||||
{ key: '/care/calculate/center?tab=prepared', label: '입력처 가정산 리스트' },
|
||||
{ key: '/care/calculate/center?tab=waiting', label: '입력처 정산대기 리스트' },
|
||||
{ key: '/care/calculate/center?tab=completed', label: '입력처 정산완료 리스트' },
|
||||
{ key: '/care/calculate/center?tab=transferred', label: '입금완료 리스트' },
|
||||
{ key: '/care/invoice/publish', label: '정산서발행' },
|
||||
{ key: '/care/invoice/my', label: '나의정산서' },
|
||||
{ key: '/care/invoice/log', label: '정산서이력' },
|
||||
@@ -109,6 +124,14 @@ export function careMenuSections(): MenuSection[] {
|
||||
{ key: '/care/setting/level', label: '권한설정' },
|
||||
{ key: '/care/setting/partner1', label: '상부점관리' },
|
||||
{ key: '/care/setting/partner2', label: '하부점관리' },
|
||||
{ key: '/care/setting/isp', label: '통신사관리' },
|
||||
{ key: '/care/setting/center', label: '입력처관리' },
|
||||
{ key: '/care/setting/isp-link', label: '통신사인증링크' },
|
||||
{ key: '/care/setting/board-admin', label: '게시판관리' },
|
||||
{ key: '/care/setting/fee', label: '수수료관리' },
|
||||
{ key: '/care/setting/quick-delivery', label: '퀵전송 연동' },
|
||||
{ key: '/care/setting/configure', label: '기타 환경 설정' },
|
||||
{ key: '/care/setting/usage', label: '이용현황' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -162,6 +185,7 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
상품관리: 'product',
|
||||
계약관리: 'contract',
|
||||
접수관리: 'reception',
|
||||
영업점관리: 'branch',
|
||||
정산관리: 'invoice',
|
||||
일정관리: 'pending',
|
||||
예약관리: 'booking',
|
||||
@@ -174,17 +198,28 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
게시판: 'bbs',
|
||||
}
|
||||
|
||||
export function resolveSelectedKey(pathname: string): string {
|
||||
export function resolveSelectedKey(pathname: string, search?: string): string {
|
||||
if (pathname.startsWith('/care/contract/write')) {
|
||||
const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('type') : null
|
||||
const q = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('type')
|
||||
if (q === 'home') return '/care/contract/home'
|
||||
return '/care/contract/internet'
|
||||
}
|
||||
if (pathname === '/care/sitemap') return '/care/main/sitemap'
|
||||
if (pathname.startsWith('/care/calculate/')) {
|
||||
const tab = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('tab') || 'prepared'
|
||||
return `${pathname}?tab=${tab}`
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
export function sectionContainsPath(section: MenuSection, pathname: string): boolean {
|
||||
const selected = resolveSelectedKey(pathname)
|
||||
return section.items.some((it) => selected === it.key || selected.startsWith(`${it.key}/`))
|
||||
return section.items.some((it) => {
|
||||
if (selected === it.key) return true
|
||||
if (selected.startsWith(`${it.key}/`)) return true
|
||||
// 정산 메뉴: pathname 만으로도 섹션 활성
|
||||
const itPath = it.key.split('?')[0]
|
||||
if (pathname === itPath || pathname.startsWith(`${itPath}/`)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ export default function AbooksPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="abook-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -572,7 +572,7 @@ export default function BookingPage() {
|
||||
<div className="booking-modal-note">* 고객명, 연락처는 고객관리에도 동시등록됩니다.</div>
|
||||
<Space>
|
||||
<Button className="booking-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</Space>
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
|
||||
|
||||
export type FeeEntry = {
|
||||
id: number
|
||||
type: string
|
||||
ispName?: string
|
||||
carrier?: string
|
||||
category?: string
|
||||
startDate?: string
|
||||
}
|
||||
|
||||
type FeeProduct = {
|
||||
id?: number
|
||||
productType: string
|
||||
productName: string
|
||||
fee: number
|
||||
}
|
||||
|
||||
type FeeDetail = {
|
||||
fee: {
|
||||
id: number
|
||||
type: string
|
||||
carrier?: string
|
||||
ispName?: string
|
||||
startDate?: string
|
||||
memo?: string
|
||||
source?: string
|
||||
file1?: string
|
||||
file2?: string
|
||||
file3?: string
|
||||
file4?: string
|
||||
file5?: string
|
||||
branchName?: string
|
||||
}
|
||||
products: FeeProduct[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷',
|
||||
e02: '전화',
|
||||
e03: 'TV',
|
||||
e04: '기타',
|
||||
}
|
||||
|
||||
const CARRIERS = ['KT', 'SKB', 'LGU+', 'SKT', '기타']
|
||||
|
||||
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
branchId: number | null
|
||||
branchName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 영업점 수수료 목록 → 상세보기(개별/그룹) → 저장 — billing_imeere branchFees 흐름 */
|
||||
export default function BranchFeeFlowModals({ open, branchId, branchName, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [selectedFeeKey, setSelectedFeeKey] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
|
||||
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [startDate, setStartDate] = useState<string>('')
|
||||
const [products, setProducts] = useState<FeeProduct[]>([])
|
||||
const [selectedProductKey, setSelectedProductKey] = useState<string | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery({
|
||||
queryKey: ['homes-branch-fees', branchId],
|
||||
enabled: !!branchId && open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeEntry[]>>(
|
||||
`${apiPrefix()}/homes/branches/${branchId}/fees`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['homes-branch-fee-detail', detailFeeId, detailType],
|
||||
enabled: !!detailFeeId && detailOpen,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeDetail>>(
|
||||
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
|
||||
{ params: { type: detailType } },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['homes-branch-fee-templates'],
|
||||
enabled: createOpen,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeProduct[]>>(
|
||||
`${apiPrefix()}/homes/branch-fee-product-templates`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
setMemo(detail.fee.memo || '')
|
||||
setStartDate(detail.fee.startDate || '')
|
||||
setProducts((detail.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
|
||||
setEditable(detail.fee.type === 'alone')
|
||||
}, [detail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
const rows = templates.length
|
||||
? templates.map((t) => ({ ...t, fee: 0 }))
|
||||
: [{ productType: 'e01', productName: '기가인터넷 1G', fee: 0 }]
|
||||
createForm.setFieldsValue({
|
||||
carrier: createForm.getFieldValue('carrier') || 'KT',
|
||||
startDate: createForm.getFieldValue('startDate') || dayjs().startOf('day'),
|
||||
products: rows,
|
||||
})
|
||||
}, [createOpen, templates, createForm])
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (feeId: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/branch-fees/${feeId}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.put(
|
||||
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
|
||||
{
|
||||
startDate,
|
||||
memo,
|
||||
carrier: detail?.fee.carrier || detail?.fee.ispName,
|
||||
products: products.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: p.fee ?? 0,
|
||||
})),
|
||||
},
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fee-detail', detailFeeId, detailType] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const productRows: FeeProduct[] = (values.products as FeeProduct[]) || templates
|
||||
const sd = values.startDate
|
||||
const startDateStr = sd && typeof sd === 'object' && 'format' in (sd as object)
|
||||
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
|
||||
: (sd ? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD 00:00:00'))
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/branches/${branchId}/fees`,
|
||||
{
|
||||
carrier: values.carrier,
|
||||
startDate: startDateStr,
|
||||
memo: values.memo || '',
|
||||
products: productRows.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: Number(p.fee) || 0,
|
||||
})),
|
||||
},
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('등록되었습니다.')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openDetail = (row: FeeEntry, editMode: boolean) => {
|
||||
setDetailType(row.type === 'group' ? 'group' : 'alone')
|
||||
setDetailFeeId(row.id)
|
||||
setDetailOpen(true)
|
||||
setEditable(row.type === 'alone' && editMode)
|
||||
// if open via 상세보기 for alone, still allow save (screenshot has 저장)
|
||||
if (row.type === 'alone') setEditable(true)
|
||||
if (row.type === 'group') setEditable(false)
|
||||
}
|
||||
|
||||
const backToList = () => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}
|
||||
|
||||
const titleKind = detailType === 'group' ? '그룹' : '개별'
|
||||
const files = useMemo(() => ([
|
||||
detail?.fee.file1, detail?.fee.file2, detail?.fee.file3, detail?.fee.file4, detail?.fee.file5,
|
||||
]), [detail])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DraggableModal
|
||||
title={`영업점 수수료 - ${branchName}`}
|
||||
open={open && !detailOpen && !createOpen}
|
||||
onCancel={onClose}
|
||||
width={780}
|
||||
destroyOnClose={false}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => {
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({
|
||||
carrier: 'KT',
|
||||
startDate: dayjs().startOf('day'),
|
||||
memo: '',
|
||||
})
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={onClose}>목록</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(r) => `${r.type}-${r.id}`}
|
||||
loading={isLoading}
|
||||
dataSource={entries}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelectedFeeKey(`${r.type}-${r.id}`),
|
||||
className: selectedFeeKey === `${r.type}-${r.id}` ? 'row-selected' : undefined,
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '판매처',
|
||||
dataIndex: 'ispName',
|
||||
render: (v, r) => v || r.carrier || '',
|
||||
},
|
||||
{
|
||||
title: '적용일',
|
||||
dataIndex: 'startDate',
|
||||
align: 'center',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: '분류',
|
||||
dataIndex: 'category',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (v, r) => v || (r.type === 'group' ? '그룹' : '공통'),
|
||||
},
|
||||
{
|
||||
title: '상세보기',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); openDetail(r, false) }}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수정',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, r) => (r.type === 'alone'
|
||||
? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); openDetail(r, true) }}
|
||||
>
|
||||
수정하기
|
||||
</Button>
|
||||
)
|
||||
: '-'),
|
||||
},
|
||||
{
|
||||
title: '삭제',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, r) => (r.type === 'alone'
|
||||
? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('정말로 삭제하시겠습니까?')) deleteMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)
|
||||
: '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`영업점 ${titleKind} 수수료 - ${branchName}`}
|
||||
open={detailOpen}
|
||||
onCancel={backToList}
|
||||
width={800}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button onClick={backToList}>목록으로</Button>
|
||||
{editable && (
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{detailLoading || !detail ? (
|
||||
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
|
||||
) : (
|
||||
<div className="order-form-embedded-inner order-form-page reception-apply-form branch-fee-detail">
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="출처">
|
||||
<span>{detail.fee.source || detail.fee.id}</span>
|
||||
</FormField>
|
||||
<FormField label="판매처">
|
||||
<span>{detail.fee.carrier || detail.fee.ispName || '-'}</span>
|
||||
</FormField>
|
||||
<FormField label="적용일자" full>
|
||||
{editable ? (
|
||||
<DatePicker
|
||||
showTime
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (detail.fee.startDate || '-')}
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="상품별 수수료">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
dataSource={products}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelectedProductKey(`${r.productType}-${r.productName}`),
|
||||
className: selectedProductKey === `${r.productType}-${r.productName}` ? 'row-selected' : undefined,
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '상품유형',
|
||||
dataIndex: 'productType',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (t: string) => PRODUCT_TYPE[t] || t || '기타',
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{
|
||||
title: '적용수수료',
|
||||
dataIndex: 'fee',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (fee: number, _r, idx) => (editable ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1000}
|
||||
value={fee}
|
||||
style={{ width: 120 }}
|
||||
onChange={(v) => {
|
||||
setProducts((prev) => prev.map((p, i) => (i === idx ? { ...p, fee: Number(v) || 0 } : p)))
|
||||
}}
|
||||
/>
|
||||
) : won(fee)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="기타">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="메모" full>
|
||||
{editable ? (
|
||||
<Input.TextArea rows={2} value={memo} onChange={(e) => setMemo(e.target.value)} />
|
||||
) : (memo || '')}
|
||||
</FormField>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<FormField key={n} label={`파일${n}`} full>
|
||||
<span>{files[n - 1] || ''}</span>
|
||||
</FormField>
|
||||
))}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</div>
|
||||
)}
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`영업점 개별 수수료 등록 - ${branchName}`}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
okText="등록"
|
||||
width={800}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
className="order-form-embedded-inner order-form-page reception-apply-form"
|
||||
onFinish={(v) => createMut.mutate(v)}
|
||||
initialValues={{
|
||||
carrier: 'KT',
|
||||
startDate: dayjs().startOf('day'),
|
||||
}}
|
||||
>
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="판매처(통신사)*">
|
||||
<Form.Item name="carrier" noStyle rules={[{ required: true, message: '통신사를 선택하세요' }]}>
|
||||
<Select options={CARRIERS.map((c) => ({ value: c, label: c }))} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="적용일*">
|
||||
<Form.Item name="startDate" noStyle rules={[{ required: true, message: '적용일을 입력하세요' }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="메모" full>
|
||||
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
<FormSection title="상품별 수수료">
|
||||
<Form.List name="products">
|
||||
{(fields) => (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={fields.map((f) => ({ ...f, key: f.key }))}
|
||||
columns={[
|
||||
{
|
||||
title: '상품유형',
|
||||
width: 120,
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'productType']} noStyle>
|
||||
<Select
|
||||
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상품명',
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'productName']} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '적용수수료',
|
||||
width: 130,
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'fee']} noStyle>
|
||||
<InputNumber min={0} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Form.List>
|
||||
</FormSection>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Checkbox, DatePicker, Drawer, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import PreparedDetailEditor from './PreparedDetailEditor'
|
||||
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
|
||||
|
||||
export type Kind = 'branch' | 'center'
|
||||
|
||||
export type Summary = {
|
||||
entityKey: string
|
||||
title: string
|
||||
sub: string
|
||||
contact: string
|
||||
tax: string
|
||||
group: string
|
||||
sido: string
|
||||
plusCnt: number
|
||||
minusCnt: number
|
||||
}
|
||||
|
||||
type Line = {
|
||||
id: number
|
||||
lineId: number
|
||||
type: string
|
||||
typeDesc: string | null
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
memo: string | null
|
||||
status: string
|
||||
contractNumber: string
|
||||
customerName?: string
|
||||
}
|
||||
|
||||
export type Group = {
|
||||
id: number
|
||||
entityKey?: string
|
||||
plus: number
|
||||
minus: number
|
||||
total: number
|
||||
rate: number
|
||||
isTax: number
|
||||
taxDate: string | null
|
||||
completed: string | null
|
||||
transferred: string | null
|
||||
bank: string | null
|
||||
accountNumber: string | null
|
||||
accountName: string | null
|
||||
memo: string | null
|
||||
plusCnt: number
|
||||
minusCnt: number
|
||||
branchName?: string | null
|
||||
officeName?: string | null
|
||||
centerName?: string | null
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'prepared', label: '가정산 리스트' },
|
||||
{ key: 'waiting', label: '정산대기 리스트' },
|
||||
{ key: 'completed', label: '정산완료 리스트' },
|
||||
{ key: 'transferred', label: '송금(환수)완료 리스트' },
|
||||
] as const
|
||||
type TabKey = (typeof TABS)[number]['key']
|
||||
|
||||
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
|
||||
const today = () => dayjs().format('YYYY-MM-DD')
|
||||
|
||||
/** 정산 상세 Drawer — 좌측패널까지 덮는 전체화면 */
|
||||
const fullscreenDrawerProps = {
|
||||
width: '100%' as const,
|
||||
placement: 'right' as const,
|
||||
rootClassName: 'calc-fullscreen-drawer-root',
|
||||
getContainer: () => document.body,
|
||||
zIndex: 1200,
|
||||
destroyOnClose: true,
|
||||
closable: false,
|
||||
styles: { body: { paddingTop: 8 } },
|
||||
}
|
||||
|
||||
function CalcDetailDrawerTitle({
|
||||
label,
|
||||
onBack,
|
||||
}: {
|
||||
label: string
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="calc-drawer-titlebar">
|
||||
<div className="calc-drawer-titlebar-left">
|
||||
<Button type="default" size="small" className="calc-drawer-back-btn" onClick={onBack}>
|
||||
목록으로
|
||||
</Button>
|
||||
<span className="calc-drawer-title-text">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalcDetailDrawerExtra({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Button type="default" size="small" className="calc-drawer-close-btn" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function cfg(kind: Kind) {
|
||||
const base = kind === 'branch'
|
||||
? `${apiPrefix()}/homes/calculate`
|
||||
: `${apiPrefix()}/homes/center-calculate`
|
||||
return {
|
||||
base,
|
||||
normSummary: (r: Record<string, unknown>): Summary => ({
|
||||
entityKey: String(kind === 'branch' ? (r.entityKey ?? r.branchId) : (r.entityKey ?? r.centerId)),
|
||||
title: String(kind === 'branch' ? (r.officeName ?? r.branchName ?? '') : (r.name ?? r.centerName ?? '')),
|
||||
sub: String(kind === 'branch' ? (r.name ?? '') : (r.manager ?? '')),
|
||||
contact: String(r.phone || r.tel || ''),
|
||||
tax: kind === 'branch' ? (r.isTax === 1 ? '세금계산서발행' : '종합소득세공제') : '세금계산서발행',
|
||||
group: String(kind === 'branch' ? (r.branchGroupName || '') : ''),
|
||||
sido: String(kind === 'branch' ? (r.sido || '') : ''),
|
||||
plusCnt: Number(r.plusCnt || 0),
|
||||
minusCnt: Number(r.minusCnt || 0),
|
||||
}),
|
||||
normLine: (r: Record<string, unknown>): Line => ({
|
||||
id: Number(r.id),
|
||||
lineId: Number(kind === 'branch' ? (r.orderId ?? r.receptionId) : (r.orderCenterId ?? r.receptionId)),
|
||||
type: String(r.type || ''),
|
||||
typeDesc: r.typeDesc == null ? null : String(r.typeDesc),
|
||||
cost: Number(r.cost || 0),
|
||||
event: Number(r.event || 0),
|
||||
agreement: Number(r.agreement || 0),
|
||||
local: Number(r.local || 0),
|
||||
gift: Number(r.gift || 0),
|
||||
voc: Number(r.voc || 0),
|
||||
etc: Number(r.etc || 0),
|
||||
total: Number(r.total || 0),
|
||||
memo: r.memo == null ? null : String(r.memo),
|
||||
status: String(r.status || ''),
|
||||
contractNumber: String(r.contractNumber || ''),
|
||||
customerName: r.customerName ? String(r.customerName) : undefined,
|
||||
}),
|
||||
normGroup: (r: Record<string, unknown>): Group => ({
|
||||
id: Number(r.id),
|
||||
entityKey: r.entityKey == null ? undefined : String(r.entityKey),
|
||||
plus: Number(r.plus || 0),
|
||||
minus: Number(r.minus || 0),
|
||||
total: Number(r.total || 0),
|
||||
rate: Number(r.rate || 0),
|
||||
isTax: Number(r.isTax ?? 1),
|
||||
taxDate: r.taxDate == null ? null : String(r.taxDate),
|
||||
completed: r.completed == null ? null : String(r.completed),
|
||||
transferred: r.transferred == null ? null : String(r.transferred),
|
||||
bank: r.bank == null ? null : String(r.bank),
|
||||
accountNumber: r.accountNumber == null ? null : String(r.accountNumber),
|
||||
accountName: r.accountName == null ? null : String(r.accountName),
|
||||
memo: r.memo == null ? null : String(r.memo),
|
||||
plusCnt: Number(r.plusCnt || 0),
|
||||
minusCnt: Number(r.minusCnt || 0),
|
||||
branchName: r.branchName == null ? null : String(r.branchName),
|
||||
officeName: r.officeName == null ? null : String(r.officeName),
|
||||
centerName: r.centerName == null ? null : String(r.centerName),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export default function CalculatePage({ kind }: { kind: Kind }) {
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const tabLabels = useMemo(() => {
|
||||
if (kind === 'center') {
|
||||
return TABS.map((t) => (t.key === 'transferred' ? { ...t, label: '입금완료 리스트' } : t))
|
||||
}
|
||||
return [...TABS]
|
||||
}, [kind])
|
||||
|
||||
const [tab, setTab] = useState<TabKey>(() => {
|
||||
const t = sp.get('tab') as TabKey
|
||||
return TABS.some((x) => x.key === t) ? t : 'prepared'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = sp.get('tab') as TabKey
|
||||
if (t && TABS.some((x) => x.key === t)) {
|
||||
setTab(t)
|
||||
} else if (!sp.get('tab')) {
|
||||
const next = new URLSearchParams(sp)
|
||||
next.set('tab', 'prepared')
|
||||
setSp(next, { replace: true })
|
||||
}
|
||||
}, [sp, setSp])
|
||||
|
||||
const changeTab = (key: TabKey) => {
|
||||
setTab(key)
|
||||
const next = new URLSearchParams(sp)
|
||||
next.set('tab', key)
|
||||
setSp(next, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-page calculate-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>{kind === 'branch' ? '영업점 정산현황' : '입력처 정산현황'}</h2>
|
||||
<p>
|
||||
{kind === 'branch'
|
||||
? '가정산 → 정산대기 → 정산완료 → 송금(환수)완료 순으로 처리합니다.'
|
||||
: '가정산 → 정산대기 → 정산완료 → 입금완료 순으로 처리합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{tabLabels.map((t) => (
|
||||
<Button key={t.key} type={tab === t.key ? 'primary' : 'default'} onClick={() => changeTab(t.key)}>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'prepared' && <PreparedTab kind={kind} />}
|
||||
{tab === 'waiting' && <WaitingTab kind={kind} />}
|
||||
{tab === 'completed' && <CompletedTab kind={kind} />}
|
||||
{tab === 'transferred' && <TransferredTab kind={kind} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreparedTab({ kind }: { kind: Kind }) {
|
||||
const c = cfg(kind)
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'prepared'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/prepared`)
|
||||
return (unwrap(data) || []).map(c.normSummary)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Summary | null>(null)
|
||||
return (
|
||||
<>
|
||||
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 가정산 상세리스트`} onBack={() => setOpen(null)} /> : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<PreparedDetailEditor
|
||||
kind={kind}
|
||||
row={open}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingTab({ kind }: { kind: Kind }) {
|
||||
const c = cfg(kind)
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'waiting'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting`)
|
||||
return (unwrap(data) || []).map(c.normSummary)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Summary | null>(null)
|
||||
return (
|
||||
<>
|
||||
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 정산대기 상세리스트`} onBack={() => setOpen(null)} /> : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<WaitingEditor
|
||||
kind={kind}
|
||||
row={open}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingEditor({ kind, row, onDone }: { kind: Kind; row: Summary; onDone: () => void }) {
|
||||
const c = cfg(kind)
|
||||
const qc = useQueryClient()
|
||||
const linesKey = ['calc', kind, 'waitingLines', row.entityKey]
|
||||
const { data } = useQuery({
|
||||
queryKey: linesKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting/lines`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return (unwrap(data) || []).map(c.normLine)
|
||||
},
|
||||
})
|
||||
const { data: completedGroups } = useQuery({
|
||||
queryKey: ['calc', kind, 'completed', 'merge', row.entityKey],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/completed`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return (unwrap(data) || []).map(c.normGroup)
|
||||
},
|
||||
})
|
||||
|
||||
const [edits, setEdits] = useState<Record<number, Line>>({})
|
||||
const lines = useMemo(() => (data ?? []).map((l) => edits[l.id] ?? l), [data, edits])
|
||||
const [plus, setPlus] = useState(0)
|
||||
const [minus, setMinus] = useState(0)
|
||||
const [completed, setCompleted] = useState(today())
|
||||
const [memo, setMemo] = useState('')
|
||||
const [mergeMode, setMergeMode] = useState<'new' | 'merge'>('new')
|
||||
const [mergeGroupId, setMergeGroupId] = useState<number | undefined>()
|
||||
const total = lines.reduce((a, l) => a + l.total, 0) + plus + minus
|
||||
|
||||
const patch = (id: number, field: keyof Line, value: number) => {
|
||||
setEdits((prev) => {
|
||||
const base = prev[id] ?? data!.find((d) => d.id === id)!
|
||||
const next = { ...base, [field]: value }
|
||||
next.total = next.cost + next.event + next.agreement + next.local + next.gift + next.voc + next.etc
|
||||
return { ...prev, [id]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const saveLine = useMutation({
|
||||
mutationFn: async (l: Line) => {
|
||||
const { data } = await client.post(`${c.base}/cost/${l.id}`, {
|
||||
cost: l.cost, event: l.event, agreement: l.agreement, local: l.local,
|
||||
gift: l.gift, voc: l.voc, etc: l.etc, memo: l.memo,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { setEdits({}); qc.invalidateQueries({ queryKey: linesKey }); message.success('저장됨') },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const revert = useMutation({
|
||||
mutationFn: async (lineId: number) => {
|
||||
const { data } = await client.post(`${c.base}/to-prepared`, { receptionIds: [lineId] })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: linesKey })
|
||||
qc.invalidateQueries({ queryKey: ['calc', kind] })
|
||||
message.success('가정산으로 되돌렸습니다.')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (mergeMode === 'merge' && !mergeGroupId) throw new Error('결합할 기존 그룹을 선택하세요.')
|
||||
const { data } = await client.post(`${c.base}/toss-completed`, {
|
||||
entity: row.entityKey,
|
||||
plus, minus, completed, memo,
|
||||
mergeGroupId: mergeMode === 'merge' ? mergeGroupId : null,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('정산완료 처리되었습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
|
||||
{ title: '고객', width: 90, dataIndex: 'customerName' },
|
||||
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
|
||||
...(['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc'] as const).map((f) => ({
|
||||
title: ({ cost: '단가', event: '이벤트', agreement: '약정', local: '지역', gift: '사은품', voc: 'VOC', etc: '기타' } as const)[f],
|
||||
width: 90,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={r[f]}
|
||||
style={{ width: 80 }}
|
||||
className="calc-money-input"
|
||||
formatter={(v) => `${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0}
|
||||
onChange={(v) => patch(r.id, f, Number(v || 0))}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
{ title: '합계', width: 100, align: 'right' as const, render: (_: unknown, r: Line) => <b>{won(r.total)}</b> },
|
||||
{
|
||||
title: '작업',
|
||||
width: 150,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="primary" className="branch-row-btn" onClick={(e) => { e.stopPropagation(); saveLine.mutate(r) }}>저장</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('이 접수를 가정산으로 되돌립니다.')) revert.mutate(r.lineId)
|
||||
}}
|
||||
>
|
||||
되돌리기
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
|
||||
<span>기타차감 <InputNumber value={minus} onChange={(v) => setMinus(Number(v || 0))} /></span>
|
||||
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 180 }} /></span>
|
||||
</Space>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Radio.Group value={mergeMode} onChange={(e) => { setMergeMode(e.target.value); setMergeGroupId(undefined) }}>
|
||||
<Radio value="new">새 그룹으로 정산완료</Radio>
|
||||
<Radio value="merge" disabled={!completedGroups?.length}>기존 그룹에 결합</Radio>
|
||||
</Radio.Group>
|
||||
{mergeMode === 'merge' && (
|
||||
<Select
|
||||
style={{ minWidth: 360, marginLeft: 12 }}
|
||||
placeholder="결합할 그룹 선택"
|
||||
value={mergeGroupId}
|
||||
onChange={setMergeGroupId}
|
||||
options={(completedGroups || []).map((g) => ({
|
||||
value: g.id,
|
||||
label: `#${g.id} 정산일 ${g.completed?.slice(0, 10) || '-'} · ${won(g.total)}원 · 정산 ${g.plusCnt}/환수 ${g.minusCnt}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 12, alignItems: 'center' }}>
|
||||
<span>최종합계 <b>{won(total)}</b> 원</span>
|
||||
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()} disabled={!lines.length}>
|
||||
정산완료 처리
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletedTab({ kind }: { kind: Kind }) {
|
||||
return <GroupTable kind={kind} mode="completed" />
|
||||
}
|
||||
|
||||
function TransferredTab({ kind }: { kind: Kind }) {
|
||||
return <GroupTable kind={kind} mode="transferred" />
|
||||
}
|
||||
|
||||
function GroupTable({ kind, mode }: { kind: Kind; mode: 'completed' | 'transferred' }) {
|
||||
const c = cfg(kind)
|
||||
const endpoint = mode === 'completed' ? 'completed' : 'transferred'
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, endpoint],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/${endpoint}`)
|
||||
return (unwrap(data) || []).map(c.normGroup)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Group | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
dataSource={data || []}
|
||||
pagination={{ pageSize: 20 }}
|
||||
onRow={(r) => ({ onClick: () => setOpen(r), style: { cursor: 'pointer' } })}
|
||||
columns={[
|
||||
{ title: '그룹#', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: kind === 'branch' ? '영업점' : '입력처',
|
||||
render: (_: unknown, r: Group) => r.officeName || r.branchName || r.centerName || '-',
|
||||
},
|
||||
{ title: '정산', width: 70, dataIndex: 'plusCnt', align: 'right' as const },
|
||||
{ title: '환수', width: 70, dataIndex: 'minusCnt', align: 'right' as const },
|
||||
{ title: '합계', width: 110, align: 'right' as const, render: (v: number) => won(v) },
|
||||
{ title: '완료일', width: 120, render: (_: unknown, r: Group) => r.completed?.slice(0, 10) || '-' },
|
||||
{
|
||||
title: kind === 'branch' ? '송금일' : '입금일',
|
||||
width: 120,
|
||||
render: (_: unknown, r: Group) => r.transferred?.slice(0, 10) || '-',
|
||||
},
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '상세보기',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Group) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn detail-view-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(r)
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? (
|
||||
<CalcDetailDrawerTitle
|
||||
label={
|
||||
mode === 'completed'
|
||||
? `정산 #${open.id} — 정산완료 상세리스트`
|
||||
: `정산 #${open.id} — ${kind === 'branch' ? '송금(환수)완료' : '입금완료'} 상세리스트`
|
||||
}
|
||||
onBack={() => setOpen(null)}
|
||||
/>
|
||||
) : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<TransferEditor
|
||||
kind={kind}
|
||||
group={open}
|
||||
editMode={mode}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TransferEditor({
|
||||
kind, group, editMode, onDone,
|
||||
}: {
|
||||
kind: Kind
|
||||
group: Group
|
||||
editMode: 'completed' | 'transferred'
|
||||
onDone: () => void
|
||||
}) {
|
||||
const c = cfg(kind)
|
||||
const isTransferredEdit = editMode === 'transferred' || !!group.transferred
|
||||
const { data: lines } = useQuery({
|
||||
queryKey: ['calc', kind, 'groupLines', group.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/group/${group.id}/lines`)
|
||||
return (unwrap(data) || []).map(c.normLine)
|
||||
},
|
||||
})
|
||||
|
||||
const [ratePercent, setRatePercent] = useState(group.isTax === 1 ? 10 : -3.3)
|
||||
const [taxBill, setTaxBill] = useState(group.isTax === 1)
|
||||
const [taxDate, setTaxDate] = useState(group.taxDate?.slice(0, 10) || today())
|
||||
const [transferred, setTransferred] = useState(group.transferred?.slice(0, 10) || today())
|
||||
const [plus, setPlus] = useState(group.plus ?? 0)
|
||||
const [minus, setMinus] = useState(group.minus ?? 0)
|
||||
const [completed, setCompleted] = useState(group.completed?.slice(0, 10) || today())
|
||||
const [memo, setMemo] = useState(group.memo || '')
|
||||
const [bank, setBank] = useState(group.bank || '')
|
||||
const [accountNumber, setAccountNumber] = useState(group.accountNumber || '')
|
||||
const [accountName, setAccountName] = useState(group.accountName || '')
|
||||
const tax = Math.round(group.total * (ratePercent / 100))
|
||||
const payout = group.total + tax
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${c.base}/group/${group.id}/toss-transferred`, {
|
||||
rate: ratePercent, taxBill, taxDate, transferred,
|
||||
bank, accountNumber, accountName, memo,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(kind === 'branch' ? '송금완료 처리되었습니다.' : '입금완료 처리되었습니다.')
|
||||
onDone()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
const path = isTransferredEdit
|
||||
? `${c.base}/group/${group.id}/transferred`
|
||||
: `${c.base}/group/${group.id}`
|
||||
const body = isTransferredEdit
|
||||
? { plus, minus, completed, transferred, rate: ratePercent, taxBill, taxDate, memo, bank, accountNumber, accountName }
|
||||
: { plus, minus, completed, memo }
|
||||
const { data } = await client.post(path, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('수정 저장되었습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${c.base}/group/${group.id}/cancel`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('정산대기로 되돌렸습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines || []}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
|
||||
{ title: '고객', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
|
||||
{ title: '단가', dataIndex: 'cost', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '이벤트', dataIndex: 'event', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '약정', dataIndex: 'agreement', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '지역', dataIndex: 'local', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '사은품', dataIndex: 'gift', width: 80, align: 'right' as const, render: won },
|
||||
{ title: 'VOC', dataIndex: 'voc', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '기타', dataIndex: 'etc', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '합계', dataIndex: 'total', width: 90, align: 'right' as const, render: (v: number) => <b>{won(v)}</b> },
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<b>{isTransferredEdit ? (kind === 'branch' ? '송금완료 수정' : '입금완료 수정') : '정산완료 수정'}</b>
|
||||
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
|
||||
<span>기타차감 <InputNumber value={minus} onChange={(v) => {
|
||||
const n = Number(v || 0)
|
||||
setMinus(n > 0 ? -Math.abs(n) : n)
|
||||
}} /></span>
|
||||
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
{isTransferredEdit && (
|
||||
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
|
||||
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
|
||||
</span>
|
||||
)}
|
||||
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 160 }} /></span>
|
||||
{kind === 'branch' && isTransferredEdit && (
|
||||
<>
|
||||
<span>은행 <Input value={bank} onChange={(e) => setBank(e.target.value)} style={{ width: 80 }} /></span>
|
||||
<span>계좌 <Input value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} style={{ width: 120 }} /></span>
|
||||
<span>예금주 <Input value={accountName} onChange={(e) => setAccountName(e.target.value)} style={{ width: 80 }} /></span>
|
||||
</>
|
||||
)}
|
||||
<Button type="primary" className="branch-row-btn" onClick={() => update.mutate()}>수정 저장</Button>
|
||||
</Space>
|
||||
|
||||
{!isTransferredEdit && (
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<Checkbox checked={taxBill} onChange={(e) => {
|
||||
setTaxBill(e.target.checked)
|
||||
setRatePercent(e.target.checked ? 10 : -3.3)
|
||||
}}>세금계산서 발행</Checkbox>
|
||||
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
|
||||
{taxBill && (
|
||||
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
)}
|
||||
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
|
||||
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
|
||||
</span>
|
||||
<span style={{ marginLeft: 8 }}>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
|
||||
<Button type="primary" className="branch-row-btn" loading={submit.isPending} onClick={() => submit.mutate()}>
|
||||
{kind === 'branch' ? '송금완료' : '입금완료'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
if (window.confirm('정산완료를 취소하고 정산대기로 되돌립니다.')) cancel.mutate()
|
||||
}}
|
||||
>
|
||||
정산취소
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{isTransferredEdit && (
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<Checkbox checked={taxBill} onChange={(e) => {
|
||||
setTaxBill(e.target.checked)
|
||||
setRatePercent(e.target.checked ? 10 : -3.3)
|
||||
}}>세금계산서 발행</Checkbox>
|
||||
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
|
||||
{taxBill && (
|
||||
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
)}
|
||||
<span>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryTable({
|
||||
rows, kind, loading, onOpen,
|
||||
}: {
|
||||
rows?: Summary[]
|
||||
kind: Kind
|
||||
loading?: boolean
|
||||
onOpen: (row: Summary) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState({ group: '', title: '', sub: '', contact: '', sido: '', tax: '' })
|
||||
const [applied, setApplied] = useState(draft)
|
||||
const groups = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.group).filter(Boolean))).sort(), [rows])
|
||||
const sidos = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.sido).filter(Boolean))).sort(), [rows])
|
||||
const inc = (v: string | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
const filtered = (rows ?? []).filter((b) =>
|
||||
(!applied.group || b.group === applied.group)
|
||||
&& (!applied.title || inc(b.title, applied.title))
|
||||
&& (!applied.sub || inc(b.sub, applied.sub))
|
||||
&& (!applied.contact || inc(b.contact, applied.contact))
|
||||
&& (!applied.sido || b.sido === applied.sido)
|
||||
&& (!applied.tax || b.tax === applied.tax))
|
||||
|
||||
const detailCol = {
|
||||
title: '상세보기',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn detail-view-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpen(r)
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
|
||||
const branchCols = [
|
||||
{ title: '그룹명', dataIndex: 'group', width: 120 },
|
||||
{ title: '영업점명', dataIndex: 'title' },
|
||||
{ title: '이름', dataIndex: 'sub', width: 100 },
|
||||
{ title: '연락처', dataIndex: 'contact', width: 130 },
|
||||
{ title: '지역', dataIndex: 'sido', width: 90 },
|
||||
{ title: '세금계산서', dataIndex: 'tax', width: 130 },
|
||||
{
|
||||
title: '정산 / 환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
|
||||
),
|
||||
},
|
||||
detailCol,
|
||||
]
|
||||
|
||||
const centerCols = [
|
||||
{ title: '입력처명', dataIndex: 'title' },
|
||||
{ title: '이름', dataIndex: 'sub', width: 100 },
|
||||
{ title: '연락처', dataIndex: 'contact', width: 130 },
|
||||
{
|
||||
title: '정산 / 환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
|
||||
),
|
||||
},
|
||||
detailCol,
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="summary-table-wrap">
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
{kind === 'branch' && (
|
||||
<>
|
||||
<Select allowClear placeholder="그룹" style={{ width: 140 }} value={draft.group || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, group: v || '' }))}
|
||||
options={groups.map((g) => ({ value: g, label: g }))} />
|
||||
<Input placeholder="영업점명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 140 }} />
|
||||
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
|
||||
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
|
||||
<Select allowClear placeholder="지역" style={{ width: 110 }} value={draft.sido || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, sido: v || '' }))}
|
||||
options={sidos.map((s) => ({ value: s, label: s }))} />
|
||||
<Select allowClear placeholder="세금계산서" style={{ width: 150 }} value={draft.tax || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, tax: v || '' }))}
|
||||
options={[
|
||||
{ value: '세금계산서발행', label: '세금계산서발행' },
|
||||
{ value: '종합소득세공제', label: '종합소득세공제' },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
{kind === 'center' && (
|
||||
<>
|
||||
<Input placeholder="입력처명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 160 }} />
|
||||
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
|
||||
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
|
||||
</>
|
||||
)}
|
||||
<Button type="primary" onClick={() => setApplied({ ...draft })}>검색</Button>
|
||||
</Space>
|
||||
<p className="muted" style={{ marginBottom: 8, fontSize: 12 }}>행 클릭 또는 상세보기 버튼으로 상세리스트가 열립니다.</p>
|
||||
<Table
|
||||
rowKey="entityKey"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
pagination={{ pageSize: 30 }}
|
||||
onRow={(r) => ({ onClick: () => onOpen(r), style: { cursor: 'pointer' } })}
|
||||
columns={kind === 'branch' ? branchCols : centerCols}
|
||||
scroll={{ x: kind === 'branch' ? 1100 : 700 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -448,7 +448,7 @@ export default function ContractBalancePage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="balance-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -424,7 +424,7 @@ export default function ContractWritePage() {
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate(back)}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정합니다' : '등록'}</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정' : '등록'}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -660,7 +660,7 @@ export default function CustomerPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function DeliveryPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { Button, ConfigProvider, Table, theme as antTheme, message } from 'antd'
|
||||
import koKR from 'antd/locale/ko_KR'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
|
||||
export type FeeOrderView = {
|
||||
kind: string
|
||||
type: string
|
||||
feeId: number
|
||||
entityName: string
|
||||
ispName: string
|
||||
startDate: string
|
||||
memo?: string | null
|
||||
products: { productType: string; productName: string; fee: number }[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
|
||||
}
|
||||
|
||||
/** 정산 상세 Drawer(zIndex 1200) 위에 표시 */
|
||||
const FEE_POPUP_Z = 2200
|
||||
|
||||
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
|
||||
|
||||
let activeRoot: Root | null = null
|
||||
let activeHost: HTMLDivElement | null = null
|
||||
|
||||
function unmountFeePopup() {
|
||||
if (activeRoot) {
|
||||
try {
|
||||
activeRoot.unmount()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
activeRoot = null
|
||||
}
|
||||
if (activeHost) {
|
||||
activeHost.remove()
|
||||
activeHost = null
|
||||
}
|
||||
}
|
||||
|
||||
function mountFeePopup(node: ReactNode) {
|
||||
unmountFeePopup()
|
||||
const host = document.createElement('div')
|
||||
host.className = 'fee-order-popup-host'
|
||||
document.body.appendChild(host)
|
||||
activeHost = host
|
||||
const root = createRoot(host)
|
||||
activeRoot = root
|
||||
root.render(
|
||||
<ConfigProvider locale={koKR} theme={{ algorithm: antTheme.defaultAlgorithm }}>
|
||||
{node}
|
||||
</ConfigProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* billing_imeere branch-group-fee/order · center-fee/order 대응.
|
||||
* 수수료 있으면 현황 모달, 없으면 "적용가능한 수수료가 없습니다." 알림.
|
||||
*/
|
||||
export async function openFeeOrderPopup(receptionId: number, kind: 'branch' | 'center') {
|
||||
if (!receptionId) {
|
||||
showAlertModal('접수 정보를 확인할 수 없습니다.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<FeeOrderView>>(
|
||||
`${apiPrefix()}/homes/fee/order/${receptionId}`,
|
||||
{ params: { kind } },
|
||||
)
|
||||
const view = unwrap(data)
|
||||
showFeeStatusModal(view, kind)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '적용가능한 수수료가 없습니다.'
|
||||
showAlertModal(msg || '적용가능한 수수료가 없습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function showAlertModal(content: string) {
|
||||
const close = () => unmountFeePopup()
|
||||
mountFeePopup(
|
||||
<DraggableModal
|
||||
open
|
||||
title="수수료 현황"
|
||||
centered
|
||||
zIndex={FEE_POPUP_Z}
|
||||
onCancel={close}
|
||||
className="calc-fee-alert-modal"
|
||||
footer={(
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={close}>확인</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div>{content}</div>
|
||||
</DraggableModal>,
|
||||
)
|
||||
}
|
||||
|
||||
function showFeeStatusModal(view: FeeOrderView, kind: 'branch' | 'center') {
|
||||
const entityLabel = kind === 'center' ? '입력처' : '영업점'
|
||||
const close = () => unmountFeePopup()
|
||||
|
||||
mountFeePopup(
|
||||
<DraggableModal
|
||||
open
|
||||
title="수수료 현황"
|
||||
width={720}
|
||||
centered
|
||||
zIndex={FEE_POPUP_Z}
|
||||
onCancel={close}
|
||||
className="calc-fee-status-modal"
|
||||
footer={(
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" className="branch-row-btn" onClick={close}>닫기</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="fee-order-popup">
|
||||
<table className="prg-table" style={{ marginBottom: 12 }}>
|
||||
<tbody>
|
||||
<tr><th style={{ width: 110 }}>{entityLabel}</th><td>{view.entityName}</td></tr>
|
||||
<tr><th>통신사</th><td>{view.ispName}</td></tr>
|
||||
<tr><th>적용날짜</th><td>{view.startDate}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>상품별 수수료</div>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
dataSource={view.products || []}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '상품종류',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_: unknown, r) => PRODUCT_TYPE[r.productType] || r.productType,
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName', align: 'center' },
|
||||
{
|
||||
title: '적용금액',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_: unknown, r) => won(r.fee),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{view.memo ? (
|
||||
<table className="prg-table" style={{ marginTop: 12 }}>
|
||||
<tbody>
|
||||
<tr><th style={{ width: 110 }}>메모</th><td style={{ whiteSpace: 'pre-wrap' }}>{view.memo}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
</DraggableModal>,
|
||||
)
|
||||
}
|
||||
|
||||
/** 행 클릭 시 입력/체크 영역은 무시 */
|
||||
export function isFeeRowClickTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return !target.closest(
|
||||
'input,button,a,textarea,.ant-checkbox,.ant-input,.ant-input-number,.ant-select,.ant-picker',
|
||||
)
|
||||
}
|
||||
|
||||
export function toastFeeError(err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : '수수료 조회 실패')
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export default function InCompanyPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function InternalBoardPage({ code, title }: Props) {
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function OpenBalancePage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} icon={<PlusOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function OpenUnitCostPage() {
|
||||
<Radio.Group options={[{ value: '표준', label: '표준' }, { value: '공시/선택구분', label: '공시/선택구분' }]} />
|
||||
</Form.Item>
|
||||
<div className="unitcost-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정합니다' : '생성합니다'}</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정' : '생성합니다'}</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function OutCompanyPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -414,7 +414,7 @@ export default function PendingCalendarPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -495,7 +495,7 @@ export default function PendingPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Checkbox, DatePicker, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { GIFT_PLACE, GIFT_STATUS, progressLabel } from './receptionStatuses'
|
||||
import type { Kind, Summary } from './CalculatePage'
|
||||
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
|
||||
|
||||
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
|
||||
|
||||
const moneyInputProps = {
|
||||
size: 'small' as const,
|
||||
style: { width: 76, textAlign: 'right' as const },
|
||||
formatter: (v: string | number | undefined) =>
|
||||
`${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||
parser: (v: string | undefined) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0,
|
||||
}
|
||||
const today = () => dayjs().format('YYYY-MM-DD')
|
||||
|
||||
const SIDO_LIST = ['서울', '경기', '인천', '강원', '충북', '충남', '대전', '전북', '전남', '광주', '경북', '대구', '경남', '울산', '부산', '제주']
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
|
||||
}
|
||||
|
||||
export type PreparedRow = {
|
||||
orderId: number
|
||||
receptionId?: number
|
||||
status: string
|
||||
plus: boolean
|
||||
orderDate: string
|
||||
opened: string
|
||||
centerName: string
|
||||
ispName: string
|
||||
productType: string
|
||||
productName: string
|
||||
eventNames: string
|
||||
agreementName: string
|
||||
sido: string
|
||||
customerName: string
|
||||
ssidUseCount: number | null
|
||||
contractNumber: string
|
||||
minusCategory: string
|
||||
giftType: string
|
||||
giftPlace: string
|
||||
giftStatus: string
|
||||
progressMemo: string
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
prevCost: number
|
||||
prevEvent: number
|
||||
prevAgreement: number
|
||||
prevLocal: number
|
||||
prevGift: number
|
||||
prevVoc: number
|
||||
prevEtc: number
|
||||
prevTotal: number
|
||||
negateOnTransfer: boolean
|
||||
}
|
||||
|
||||
type DetailResponse = {
|
||||
branch?: { branchId: string; officeName: string; name: string; phone: string; tel: string }
|
||||
center?: { centerId: string; name: string; manager: string; tel: string; phone: string }
|
||||
plus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
|
||||
minus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
|
||||
}
|
||||
|
||||
type FeeField = 'cost' | 'event' | 'agreement' | 'local' | 'gift' | 'voc' | 'etc'
|
||||
|
||||
type RowEdit = {
|
||||
memo: string
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
}
|
||||
|
||||
type FeeSuggestion = {
|
||||
orderId?: number
|
||||
orderCenterId?: number
|
||||
receptionId?: number
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
}
|
||||
|
||||
function defaultFilters() {
|
||||
return {
|
||||
orderDateStart: '2010-01-01',
|
||||
orderDateEnd: today(),
|
||||
dateType: 'order_date' as 'order_date' | 'opended',
|
||||
perPage: 50,
|
||||
sorting: 'order_date',
|
||||
orderBy: 'desc',
|
||||
productType: [] as string[],
|
||||
carrier: [] as string[],
|
||||
customerSido: [] as string[],
|
||||
customerName: '',
|
||||
contractNumber: '',
|
||||
}
|
||||
}
|
||||
|
||||
type FilterState = ReturnType<typeof defaultFilters>
|
||||
|
||||
const FEE_LABELS: Record<FeeField, string> = {
|
||||
cost: '단가',
|
||||
event: '이벤트적용',
|
||||
agreement: '약정적용',
|
||||
local: '지역적용',
|
||||
gift: '사은품차감',
|
||||
voc: 'VOC차감',
|
||||
etc: '추가정산',
|
||||
}
|
||||
|
||||
function quickRanges() {
|
||||
const now = dayjs()
|
||||
const d = (x: dayjs.Dayjs) => x.format('YYYY-MM-DD')
|
||||
const dow = now.day()
|
||||
return [
|
||||
{ label: '금일', start: d(now), end: d(now) },
|
||||
{ label: '금주', start: d(now.subtract(dow, 'day')), end: d(now.add(6 - dow, 'day')) },
|
||||
{ label: '전주', start: d(now.subtract(dow + 7, 'day')), end: d(now.subtract(dow + 1, 'day')) },
|
||||
{ label: '금월', start: d(now.startOf('month')), end: d(now.endOf('month')) },
|
||||
{ label: '전월', start: d(now.subtract(1, 'month').startOf('month')), end: d(now.subtract(1, 'month').endOf('month')) },
|
||||
{ label: '금년', start: d(now.startOf('year')), end: d(now.endOf('year')) },
|
||||
{ label: '전년', start: d(now.subtract(1, 'year').startOf('year')), end: d(now.subtract(1, 'year').endOf('year')) },
|
||||
]
|
||||
}
|
||||
|
||||
/** 가정산 상세 — billing_react PreparedDetailEditor 이식 */
|
||||
export default function PreparedDetailEditor({
|
||||
kind, row, onDone,
|
||||
}: {
|
||||
kind: Kind
|
||||
row: Summary
|
||||
onDone: () => void
|
||||
}) {
|
||||
const base = kind === 'branch'
|
||||
? `${apiPrefix()}/homes/calculate`
|
||||
: `${apiPrefix()}/homes/center-calculate`
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<FilterState>(defaultFilters)
|
||||
const [applied, setApplied] = useState<FilterState>(defaultFilters)
|
||||
const [page, setPage] = useState(0)
|
||||
const [page2, setPage2] = useState(0)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [edits, setEdits] = useState<Record<number, RowEdit>>({})
|
||||
|
||||
const applyFilter = (patch: Partial<FilterState>) => {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev, ...patch }
|
||||
setApplied(next)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const params = useMemo(() => ({
|
||||
entity: row.entityKey,
|
||||
orderDateStart: applied.orderDateStart,
|
||||
orderDateEnd: applied.orderDateEnd,
|
||||
dateType: applied.dateType,
|
||||
page,
|
||||
page2,
|
||||
perPage: applied.perPage,
|
||||
sorting: applied.sorting,
|
||||
orderBy: applied.orderBy,
|
||||
customerName: applied.customerName.trim() || undefined,
|
||||
contractNumber: applied.contractNumber.trim() || undefined,
|
||||
customerSido: applied.customerSido.length ? applied.customerSido : undefined,
|
||||
productType: applied.productType.length ? applied.productType : undefined,
|
||||
carrier: applied.carrier.length ? applied.carrier : undefined,
|
||||
}), [applied, page, page2, row.entityKey])
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'prepared-detail', row.entityKey, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<DetailResponse>>(`${base}/prepared/detail`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: suggestions } = useQuery({
|
||||
queryKey: ['calc', kind, 'suggest', row.entityKey],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeSuggestion[]>>(`${base}/suggest`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const suggestById = useMemo(() => {
|
||||
const m = new Map<number, FeeSuggestion>()
|
||||
for (const s of suggestions ?? []) {
|
||||
const id = kind === 'branch' ? (s.orderId ?? s.receptionId) : (s.orderCenterId ?? s.receptionId)
|
||||
if (id != null) m.set(id, s)
|
||||
}
|
||||
return m
|
||||
}, [suggestions, kind])
|
||||
|
||||
const carriers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const r of [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]) {
|
||||
if (r.ispName) set.add(r.ispName)
|
||||
}
|
||||
return [...set].sort()
|
||||
}, [data])
|
||||
|
||||
const initEdit = (r: PreparedRow): RowEdit => {
|
||||
const existing = edits[r.orderId]
|
||||
if (existing) return existing
|
||||
const s = suggestById.get(r.orderId)
|
||||
return {
|
||||
memo: '',
|
||||
cost: s?.cost ?? r.cost,
|
||||
event: s?.event ?? r.event,
|
||||
agreement: s?.agreement ?? r.agreement,
|
||||
local: s?.local ?? r.local,
|
||||
gift: s?.gift ?? r.gift,
|
||||
voc: s?.voc ?? r.voc,
|
||||
etc: s?.etc ?? r.etc,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!suggestions?.length || !data) return
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev }
|
||||
let changed = false
|
||||
for (const r of [...data.plus.content, ...data.minus.content]) {
|
||||
if (prev[r.orderId]) continue
|
||||
const s = suggestById.get(r.orderId)
|
||||
if (!s) continue
|
||||
next[r.orderId] = {
|
||||
memo: '',
|
||||
cost: s.cost, event: s.event, agreement: s.agreement, local: s.local,
|
||||
gift: s.gift, voc: s.voc, etc: s.etc,
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [suggestions, data, suggestById])
|
||||
|
||||
const totalOf = (e: RowEdit) => e.cost + e.event + e.agreement + e.local + e.gift + e.voc + e.etc
|
||||
|
||||
const patch = (orderId: number, field: keyof RowEdit, value: string | number) => {
|
||||
setEdits((prev) => {
|
||||
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
|
||||
const base = prev[orderId] ?? initEdit(all.find((x) => x.orderId === orderId)!)
|
||||
return { ...prev, [orderId]: { ...base, [field]: value } }
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = (id: number) => setSelected((p) => {
|
||||
const n = new Set(p)
|
||||
if (n.has(id)) n.delete(id)
|
||||
else n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) throw new Error('리스트를 선택하세요.')
|
||||
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
|
||||
const lines = ids.map((id) => {
|
||||
const r = all.find((x) => x.orderId === id)!
|
||||
const e = edits[id] ?? initEdit(r)
|
||||
return {
|
||||
receptionId: id,
|
||||
cost: e.cost, event: e.event, agreement: e.agreement, local: e.local,
|
||||
gift: e.gift, voc: e.voc, etc: e.etc, memo: e.memo,
|
||||
}
|
||||
})
|
||||
const { data: res } = await client.post(`${base}/toss-waiting`, { entity: row.entityKey, lines })
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정산대기로 등록되었습니다.')
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['calc', kind] })
|
||||
onDone()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancelOne = useMutation({
|
||||
mutationFn: async (orderId: number) => {
|
||||
const { data: res } = await client.post(`${base}/prepared/cancel`, {
|
||||
receptionIds: [orderId],
|
||||
orderIds: [orderId],
|
||||
orderCenterIds: [orderId],
|
||||
})
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('가정산을 취소했습니다.')
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ['calc'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancelMany = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) throw new Error('취소할 가정산 리스트를 선택하세요.')
|
||||
const { data: res } = await client.post(`${base}/prepared/cancel`, {
|
||||
receptionIds: ids,
|
||||
orderIds: ids,
|
||||
orderCenterIds: ids,
|
||||
})
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('선택한 가정산을 취소했습니다.')
|
||||
setSelected(new Set())
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ['calc'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const search = () => { setApplied({ ...filters }); setPage(0); setPage2(0) }
|
||||
const resetSearch = () => {
|
||||
const init = defaultFilters()
|
||||
setFilters(init)
|
||||
setApplied(init)
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
setFilters((f) => {
|
||||
const orderBy = f.sorting === col && f.orderBy === 'desc' ? 'asc' : 'desc'
|
||||
const next = { ...f, sorting: col, orderBy }
|
||||
setApplied(next)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const sortMark = (col: string) => {
|
||||
if (applied.sorting !== col) return '↕'
|
||||
return applied.orderBy === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
|
||||
const feeFields: FeeField[] = ['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc']
|
||||
const ptLabel = (code: string) => PRODUCT_TYPE[code] || code || ''
|
||||
|
||||
const plusColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
const ids = (data?.plus.content ?? []).map((r) => r.orderId)
|
||||
setSelected(e.target.checked
|
||||
? new Set([...selected, ...ids])
|
||||
: new Set([...selected].filter((id) => !ids.includes(id))))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 44,
|
||||
fixed: 'left' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '번호',
|
||||
width: 70,
|
||||
render: (_: unknown, r: PreparedRow, idx: number) => (
|
||||
<div className="center">
|
||||
<div>{r.orderId.toLocaleString()}</div>
|
||||
<div className="muted">({idx + 1 + page * applied.perPage})</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('order_date')}>{sortMark('order_date')}접수일</button>,
|
||||
width: 100,
|
||||
render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n'),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('opended')}>{sortMark('opended')}개통일</button>,
|
||||
width: 100,
|
||||
dataIndex: 'opened',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('center')}>{sortMark('center')}입력처</button>,
|
||||
width: 90,
|
||||
dataIndex: 'centerName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('isp')}>{sortMark('isp')}통신사</button>,
|
||||
width: 80,
|
||||
dataIndex: 'ispName',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('product_type')}>{sortMark('product_type')}상품종류</button>,
|
||||
width: 80,
|
||||
render: (_: unknown, r: PreparedRow) => ptLabel(r.productType),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('product')}>{sortMark('product')}상품명</button>,
|
||||
width: 120,
|
||||
dataIndex: 'productName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{ title: '이벤트', width: 80, dataIndex: 'eventNames', ellipsis: true },
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('agreement')}>{sortMark('agreement')}약정</button>,
|
||||
width: 80,
|
||||
dataIndex: 'agreementName',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('sido')}>{sortMark('sido')}지역</button>,
|
||||
width: 60,
|
||||
dataIndex: 'sido',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('name')}>{sortMark('name')}이름</button>,
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.customerName}</div>
|
||||
{r.ssidUseCount != null && r.ssidUseCount > 1 && <div style={{ color: '#c00' }}>({r.ssidUseCount})</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('contract_number')}>{sortMark('contract_number')}계약번호</button>,
|
||||
width: 110,
|
||||
dataIndex: 'contractNumber',
|
||||
},
|
||||
{
|
||||
title: '추가메모',
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
|
||||
},
|
||||
},
|
||||
...feeFields.map((f) => ({
|
||||
title: FEE_LABELS[f],
|
||||
width: 88,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return (
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
className="calc-money-input"
|
||||
value={e[f]}
|
||||
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '최종정산금액',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <b>{won(totalOf(e))}</b>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '취소',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('가정산을 취소합니다.')) cancelOne.mutate(r.orderId)
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const minusColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
const ids = (data?.minus.content ?? []).map((r) => r.orderId)
|
||||
setSelected(e.target.checked
|
||||
? new Set([...selected, ...ids])
|
||||
: new Set([...selected].filter((id) => !ids.includes(id))))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 44,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '번호',
|
||||
width: 70,
|
||||
render: (_: unknown, r: PreparedRow, idx: number) => (
|
||||
<div>
|
||||
<div>{r.orderId.toLocaleString()}</div>
|
||||
<div className="muted">({idx + 1 + page2 * applied.perPage})</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '접수일', width: 100, render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n') },
|
||||
{ title: '개통일', width: 100, dataIndex: 'opened' },
|
||||
{ title: '입력처', width: 90, dataIndex: 'centerName' },
|
||||
{
|
||||
title: '통신사/상품',
|
||||
width: 120,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div><div>{r.ispName}</div><div>{r.productName}</div></div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '고객/약정',
|
||||
width: 110,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.customerName}</div>
|
||||
<div>{r.agreementName}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '환수구분/계약번호',
|
||||
width: 130,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.minusCategory || progressLabel(r.status)}</div>
|
||||
<div>{r.contractNumber}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '사은품',
|
||||
width: 100,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.giftType}</div>
|
||||
<div>{GIFT_PLACE[r.giftPlace] || r.giftPlace}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '내역',
|
||||
width: 110,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div title={r.progressMemo}>{r.progressMemo}</div>
|
||||
<div>{GIFT_STATUS[r.giftStatus] || r.giftStatus}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '추가메모',
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '구분',
|
||||
width: 70,
|
||||
render: () => <div><div>이전정산</div><div>환수금액</div></div>,
|
||||
},
|
||||
...feeFields.map((f) => ({
|
||||
title: FEE_LABELS[f].replace('적용', '').replace('차감', ''),
|
||||
width: 88,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
const prevMap: Record<FeeField, number> = {
|
||||
cost: r.prevCost, event: r.prevEvent, agreement: r.prevAgreement, local: r.prevLocal,
|
||||
gift: r.prevGift, voc: r.prevVoc, etc: r.prevEtc,
|
||||
}
|
||||
return (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={prevMap[f]}
|
||||
disabled
|
||||
style={{ ...moneyInputProps.style, marginBottom: 2 }}
|
||||
/>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={e[f]}
|
||||
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '최종환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={r.prevTotal}
|
||||
disabled
|
||||
style={{ ...moneyInputProps.style, width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
<div><b>{won(totalOf(e))}</b></div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="prepared-detail">
|
||||
<p className="prepared-detail-subtitle"><b>{row.title}</b> — 가정산 상세페이지</p>
|
||||
|
||||
{data?.center && (
|
||||
<table className="prg-table" style={{ marginTop: 8 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>입력처명</th><td>{data.center.name}</td>
|
||||
<th>담당자</th><td>{data.center.manager}</td>
|
||||
<th>연락처</th><td>{data.center.phone}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{data?.branch && (
|
||||
<table className="prg-table" style={{ marginTop: 8 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>영업점명</th><td>{data.branch.officeName}</td>
|
||||
<th>이름</th><td>{data.branch.name}</td>
|
||||
<th>휴대전화</th><td>{data.branch.phone}</td>
|
||||
<th>일반전화</th><td>{data.branch.tel}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Space wrap style={{ marginTop: 12 }} size={8}>
|
||||
<Radio.Group
|
||||
value={filters.dateType}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, dateType: e.target.value }))}
|
||||
optionType="button"
|
||||
options={[
|
||||
{ value: 'order_date', label: '접수일' },
|
||||
{ value: 'opended', label: '개통일' },
|
||||
]}
|
||||
/>
|
||||
<DatePicker value={dayjs(filters.orderDateStart)} onChange={(d) => setFilters((f) => ({ ...f, orderDateStart: d?.format('YYYY-MM-DD') || '2010-01-01' }))} />
|
||||
<span>~</span>
|
||||
<DatePicker value={dayjs(filters.orderDateEnd)} onChange={(d) => setFilters((f) => ({ ...f, orderDateEnd: d?.format('YYYY-MM-DD') || today() }))} />
|
||||
{quickRanges().map((q) => (
|
||||
<Button key={q.label} size="small" onClick={() => {
|
||||
const next = { ...filters, orderDateStart: q.start, orderDateEnd: q.end }
|
||||
setFilters(next)
|
||||
setApplied(next)
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}}>{q.label}</Button>
|
||||
))}
|
||||
<span>한 페이지당 <InputNumber min={10} value={filters.perPage} onChange={(v) => setFilters((f) => ({ ...f, perPage: Math.max(10, Number(v || 50)) }))} style={{ width: 64 }} /></span>
|
||||
<Button type="primary" onClick={search}>검색</Button>
|
||||
<Button onClick={resetSearch}>검색취소</Button>
|
||||
</Space>
|
||||
|
||||
<Space wrap style={{ marginTop: 8 }} size={8}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="통신사"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.carrier}
|
||||
onChange={(v) => applyFilter({ carrier: v })}
|
||||
options={carriers.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="상품종류"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.productType}
|
||||
onChange={(v) => applyFilter({ productType: v })}
|
||||
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="지역"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.customerSido}
|
||||
onChange={(v) => applyFilter({ customerSido: v })}
|
||||
options={SIDO_LIST.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="고객명"
|
||||
value={filters.customerName}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, customerName: e.target.value }))}
|
||||
onPressEnter={search}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="개통번호"
|
||||
value={filters.contractNumber}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, contractNumber: e.target.value }))}
|
||||
onPressEnter={search}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<h4 style={{ marginTop: 16, fontSize: 14 }}>
|
||||
정산 리스트
|
||||
{data ? <span className="muted"> ({data.plus.totalElements})</span> : null}
|
||||
</h4>
|
||||
<Table
|
||||
className="calc-prepared-table"
|
||||
rowKey="orderId"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
scroll={{ x: 2200 }}
|
||||
pagination={data && data.plus.totalPages > 1 ? {
|
||||
current: page + 1,
|
||||
pageSize: applied.perPage,
|
||||
total: data.plus.totalElements,
|
||||
onChange: (p) => setPage(p - 1),
|
||||
showSizeChanger: false,
|
||||
} : false}
|
||||
dataSource={data?.plus.content || []}
|
||||
columns={plusColumns}
|
||||
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<h4 style={{ marginTop: 20, fontSize: 14 }}>
|
||||
해지 리스트
|
||||
{data ? <span className="muted"> ({data.minus.totalElements})</span> : null}
|
||||
</h4>
|
||||
<Table
|
||||
className="calc-prepared-table"
|
||||
rowKey="orderId"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
scroll={{ x: 2000 }}
|
||||
pagination={data && data.minus.totalPages > 1 ? {
|
||||
current: page2 + 1,
|
||||
pageSize: applied.perPage,
|
||||
total: data.minus.totalElements,
|
||||
onChange: (p) => setPage2(p - 1),
|
||||
showSizeChanger: false,
|
||||
} : false}
|
||||
dataSource={data?.minus.content || []}
|
||||
columns={minusColumns}
|
||||
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Space style={{ marginTop: 16 }} size={8}>
|
||||
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()}>
|
||||
정산대기로 보내기
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
loading={cancelMany.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('선택한 가정산을 취소합니다.')) cancelMany.mutate()
|
||||
}}
|
||||
>
|
||||
가정산 일괄 취소
|
||||
</Button>
|
||||
<span className="muted" style={{ fontSize: 12 }}>선택 {selected.size}건 · 행 클릭: 수수료 현황</span>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ export default function ProductHomePage() {
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function ProductInternetPage() {
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -18,29 +18,18 @@ import ReceptionProgressModal from '../../components/ReceptionProgressModal'
|
||||
import { DownOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
/** 진행현황 배지 색상 클래스 */
|
||||
function progressBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
|
||||
if (s.startsWith('2.') || s.includes('고객통화')) return 'badge badge-ps-2'
|
||||
if (s.startsWith('3.') || s.includes('서류검토')) return 'badge badge-ps-3'
|
||||
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
|
||||
if (s.startsWith('5.') || s.includes('사은품지급')) return 'badge badge-ps-5'
|
||||
if (s.startsWith('6.') || s.includes('정산완료')) return 'badge badge-ps-6'
|
||||
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
|
||||
return 'badge'
|
||||
}
|
||||
|
||||
/** 사은품현황 배지 색상 클래스 */
|
||||
function giftBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s === '미지급') return 'badge badge-gs-none'
|
||||
if (s === '지급대기') return 'badge badge-gs-wait'
|
||||
if (s === '지급완료') return 'badge badge-gs-done'
|
||||
if (s === '반송') return 'badge badge-gs-return'
|
||||
return 'badge'
|
||||
}
|
||||
import {
|
||||
progressBadgeClass,
|
||||
giftBadgeClass,
|
||||
progressLabel,
|
||||
giftLabel,
|
||||
centerLabel,
|
||||
giftPlaceLabel as placeLabel,
|
||||
statusOptions,
|
||||
PROGRESS_STATUS,
|
||||
GIFT_STATUS,
|
||||
CENTER_STATUS,
|
||||
} from './receptionStatuses'
|
||||
|
||||
type ReceptionRow = {
|
||||
id: number
|
||||
@@ -64,9 +53,12 @@ type ReceptionRow = {
|
||||
region?: string
|
||||
sido?: string
|
||||
progressStatus?: string
|
||||
progressStatusLabel?: string
|
||||
status?: string
|
||||
settleStatus?: string
|
||||
settleStatusLabel?: string
|
||||
centerStatus?: string
|
||||
centerStatusLabel?: string
|
||||
centerName?: string
|
||||
openDate?: string
|
||||
opened?: string
|
||||
@@ -75,6 +67,7 @@ type ReceptionRow = {
|
||||
giftDate?: string
|
||||
giftSended?: string
|
||||
giftStatus?: string
|
||||
giftStatusLabel?: string
|
||||
giftPlace?: string
|
||||
giftAmount?: number
|
||||
agreementName?: string
|
||||
@@ -91,6 +84,9 @@ type ReceptionResult = {
|
||||
progressStatuses?: string[]
|
||||
settleStatuses?: string[]
|
||||
giftStatuses?: string[]
|
||||
progressStatusLabels?: Record<string, string>
|
||||
giftStatusLabels?: Record<string, string>
|
||||
settleStatusLabels?: Record<string, string>
|
||||
carriers?: string[]
|
||||
branches?: string[]
|
||||
counselors?: string[]
|
||||
@@ -105,13 +101,6 @@ const SIDO_LIST = [
|
||||
'경북', '대구', '경남', '울산', '부산', '제주',
|
||||
]
|
||||
|
||||
const GIFT_PLACE_LABEL: Record<string, string> = {
|
||||
b01: '본사요청',
|
||||
b02: '자체발송',
|
||||
본사: '본사발송',
|
||||
영업점: '영업점발송',
|
||||
}
|
||||
|
||||
const initialFilters = () => ({
|
||||
page: 0,
|
||||
size: 50,
|
||||
@@ -144,10 +133,7 @@ const initialFilters = () => ({
|
||||
|
||||
const num = (n?: number | null) => (n == null ? '' : Number(n).toLocaleString())
|
||||
|
||||
const giftPlaceLabel = (v?: string) => {
|
||||
if (!v) return ''
|
||||
return GIFT_PLACE_LABEL[v] || v
|
||||
}
|
||||
const giftPlaceLabel = (v?: string) => placeLabel(v)
|
||||
|
||||
const splitDt = (v?: string) => {
|
||||
if (!v) return ['', '']
|
||||
@@ -221,11 +207,17 @@ export default function ReceptionListPage() {
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const progressStatuses = query.data?.progressStatuses || []
|
||||
const settleStatuses = query.data?.settleStatuses || []
|
||||
const giftStatuses = query.data?.giftStatuses || []
|
||||
const progressStatuses = query.data?.progressStatuses || Object.keys(PROGRESS_STATUS)
|
||||
const giftStatuses = query.data?.giftStatuses || Object.keys(GIFT_STATUS)
|
||||
const progressLabels = query.data?.progressStatusLabels || PROGRESS_STATUS
|
||||
const giftLabels = query.data?.giftStatusLabels || GIFT_STATUS
|
||||
const settleLabels = query.data?.settleStatusLabels || CENTER_STATUS
|
||||
const list = query.data?.list || []
|
||||
|
||||
const progressSelectOpts = statusOptions(progressLabels as Record<string, string>)
|
||||
const giftSelectOpts = statusOptions(giftLabels as Record<string, string>)
|
||||
const settleSelectOpts = statusOptions(settleLabels as Record<string, string>)
|
||||
|
||||
const setD = (patch: Partial<ReturnType<typeof initialFilters>>) => setDraft((p) => ({ ...p, ...patch }))
|
||||
const applyF = (patch: Partial<ReturnType<typeof initialFilters>>) => {
|
||||
const next = { ...draft, ...patch, page: 0 }
|
||||
@@ -416,8 +408,6 @@ export default function ReceptionListPage() {
|
||||
<div className="toolbar order-top-buttons">
|
||||
<Button type="primary" onClick={search}>검색</Button>
|
||||
<Button onClick={reset}>검색 초기화</Button>
|
||||
<Button onClick={downloadExcel}>Excel 다운로드</Button>
|
||||
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
|
||||
</div>
|
||||
|
||||
<div className="order-filter">
|
||||
@@ -456,6 +446,13 @@ export default function ReceptionListPage() {
|
||||
onChange={(e) => setD({ size: Number(e.target.value) || 50 })}
|
||||
/>
|
||||
</label>
|
||||
<div className="order-attach-buttons">
|
||||
<Button onClick={downloadExcel}>Excel 다운로드</Button>
|
||||
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-result-row">
|
||||
<span className="order-result muted">검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건</span>
|
||||
</div>
|
||||
|
||||
@@ -532,7 +529,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="입력처"
|
||||
value={draft.settleStatus}
|
||||
onChange={(v) => applyF({ settleStatus: v })}
|
||||
options={options(settleStatuses)}
|
||||
options={settleSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -604,7 +601,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="진행현황"
|
||||
value={draft.progressStatus}
|
||||
onChange={(v) => applyF({ progressStatus: v })}
|
||||
options={options(progressStatuses)}
|
||||
options={progressSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -639,7 +636,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="사은품진행"
|
||||
value={draft.giftStatus}
|
||||
onChange={(v) => applyF({ giftStatus: v })}
|
||||
options={options(giftStatuses)}
|
||||
options={giftSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -701,7 +698,7 @@ export default function ReceptionListPage() {
|
||||
</td>
|
||||
<td className="col-status floating-cell">
|
||||
<p className="c-green">{o.employeeName || o.counselor || ''}</p>
|
||||
<p><span className={progressBadgeClass(o.status || o.progressStatus)}>{o.status || o.progressStatus || ''}</span></p>
|
||||
<p><span className={progressBadgeClass(o.progressStatus || o.status)}>{o.progressStatusLabel || progressLabel(o.progressStatus || o.status) || ''}</span></p>
|
||||
<div className="floating-layer">
|
||||
<p>개통일: {o.opened || o.openDate || '-'}</p>
|
||||
<p>해지일: {o.canceled || '-'}</p>
|
||||
@@ -717,13 +714,13 @@ export default function ReceptionListPage() {
|
||||
<p className="fs11">{o.giftSended || o.giftDate || ''}</p>
|
||||
</td>
|
||||
<td className="col-gift">
|
||||
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatus || ''}</span></p>
|
||||
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatusLabel || giftLabel(o.giftStatus) || ''}</span></p>
|
||||
<p>{num(giftAmt)}</p>
|
||||
</td>
|
||||
<td className="col-cstatus floating-cell">
|
||||
{o.centerStatus || o.settleStatus || ''}
|
||||
{o.centerStatusLabel || centerLabel(o.centerStatus || o.settleStatus) || ''}
|
||||
<div className="floating-layer right">
|
||||
<p>정산상태: {o.settleStatus || '-'}</p>
|
||||
<p>입력처정산: {o.centerStatusLabel || centerLabel(o.settleStatus) || '-'}</p>
|
||||
<p>개통일: {o.opened || o.openDate || '-'}</p>
|
||||
<p>해지일: {o.canceled || o.cancelDate || '-'}</p>
|
||||
</div>
|
||||
@@ -788,13 +785,13 @@ export default function ReceptionListPage() {
|
||||
<Form.Item name="customerName" label="고객명" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="연락처"><Input /></Form.Item>
|
||||
<Form.Item name="progressStatus" label="진행상황">
|
||||
<Select options={options(progressStatuses)} />
|
||||
<Select options={progressSelectOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="settleStatus" label="정산상태">
|
||||
<Select options={options(settleStatuses)} />
|
||||
<Form.Item name="settleStatus" label="입력처정산">
|
||||
<Select options={settleSelectOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="giftStatus" label="사은품진행">
|
||||
<Select options={options(giftStatuses)} />
|
||||
<Select options={giftSelectOpts} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="balance-modal-footer">
|
||||
|
||||
@@ -416,7 +416,7 @@ export default function SettingAgencyPage() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Input, Select, Space, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Board = {
|
||||
id: number
|
||||
name?: string
|
||||
listLevel: number
|
||||
readLevel: number
|
||||
writeLevel: number
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ value: 1, label: '영업점(1)' },
|
||||
{ value: 5, label: '센터(5)' },
|
||||
{ value: 9, label: '관리자(9)' },
|
||||
{ value: 10, label: '최고관리자(10)' },
|
||||
]
|
||||
|
||||
const empty = { name: '', listLevel: 1, readLevel: 1, writeLevel: 9 }
|
||||
|
||||
const lvlLabel = (v: number) => LEVELS.find((x) => x.value === v)?.label || String(v)
|
||||
|
||||
export default function SettingBoardAdminPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState<{ id?: number; name: string; listLevel: number; readLevel: number; writeLevel: number }>(empty)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-boards'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Board[]>>(`${apiPrefix()}/homes/boards`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-boards'] })
|
||||
const reset = () => setForm(empty)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
name: form.name,
|
||||
listLevel: form.listLevel,
|
||||
readLevel: form.readLevel,
|
||||
writeLevel: form.writeLevel,
|
||||
}
|
||||
if (form.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/boards/${form.id}`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/boards`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
|
||||
reset()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/boards/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
dataIndex: 'seq',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v?: number) => v ?? '-',
|
||||
},
|
||||
{ title: '게시판명', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '목록권한',
|
||||
dataIndex: 'listLevel',
|
||||
width: 120,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '읽기권한',
|
||||
dataIndex: 'readLevel',
|
||||
width: 120,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '쓰기권한',
|
||||
dataIndex: 'writeLevel',
|
||||
width: 130,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Board) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
listLevel: row.listLevel,
|
||||
readLevel: row.readLevel,
|
||||
writeLevel: row.writeLevel,
|
||||
})
|
||||
}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[removeMut],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
게시판관리
|
||||
</h2>
|
||||
<p>게시판 정의와 목록/읽기/쓰기 권한 레벨을 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap align="end">
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>게시판명</div>
|
||||
<Input
|
||||
placeholder="게시판명"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>목록 권한</div>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={form.listLevel}
|
||||
onChange={(v) => setForm({ ...form, listLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>읽기 권한</div>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={form.readLevel}
|
||||
onChange={(v) => setForm({ ...form, readLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>쓰기 권한</div>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={form.writeLevel}
|
||||
onChange={(v) => setForm({ ...form, writeLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!form.name.trim()}
|
||||
loading={saveMut.isPending}
|
||||
onClick={() => saveMut.mutate()}
|
||||
>
|
||||
{form.id ? '수정' : '추가'}
|
||||
</Button>
|
||||
{form.id && <Button onClick={reset}>취소</Button>}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 게시판이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type CenterRow = {
|
||||
id: number
|
||||
name?: string
|
||||
tel?: string
|
||||
phone?: string
|
||||
manager?: string
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type QdCenter = {
|
||||
id: number
|
||||
name?: string
|
||||
authorized?: boolean
|
||||
baseUrl?: string | null
|
||||
}
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
export default function SettingCenterPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [authForm] = Form.useForm()
|
||||
const [editTarget, setEditTarget] = useState<CenterRow | null>(null)
|
||||
const [authTarget, setAuthTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-input-centers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<CenterRow[]>>(`${apiPrefix()}/homes/input-centers`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const qdQuery = useQuery({
|
||||
queryKey: ['homes-qd-centers'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<QdCenter[]>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/centers`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
} catch {
|
||||
return [] as QdCenter[]
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const qdMap = useMemo(
|
||||
() => new Map((qdQuery.data || []).map((c) => [c.id, c])),
|
||||
[qdQuery.data],
|
||||
)
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-input-centers'] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.resetFields()
|
||||
message.success('추가했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
|
||||
const { data } = await client.put(
|
||||
`${apiPrefix()}/homes/input-centers/${editTarget!.id}`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정했습니다.')
|
||||
setEditTarget(null)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/input-centers/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const authMut = useMutation({
|
||||
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
|
||||
const id = authTarget!.id
|
||||
if (authTarget!.mode === 'reauth') {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
|
||||
{ userid: values.userid, password: values.password },
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('완료되었습니다.')
|
||||
setAuthTarget(null)
|
||||
authForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeAuthMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('인증을 해제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CenterRow, idx: number) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" disabled={idx === 0} onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={idx === (query.data?.length ?? 0) - 1}
|
||||
onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}
|
||||
>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '센터명', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '연락처', dataIndex: 'tel', width: 130, render: (v?: string) => v || '' },
|
||||
{ title: '담당자', dataIndex: 'manager', width: 100, render: (v?: string) => v || '' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CenterRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? '사용' : '중지'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '퀵전송',
|
||||
width: 180,
|
||||
render: (_: unknown, row: CenterRow) => {
|
||||
const qd = qdMap.get(row.id)
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{qd?.authorized ? (
|
||||
<Typography.Text type="success">인증됨</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">미인증</Typography.Text>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAuthTarget({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseUrl: qd?.baseUrl,
|
||||
authorized: qd?.authorized,
|
||||
mode: qd?.authorized ? 'reauth' : 'auth',
|
||||
})
|
||||
authForm.setFieldsValue({
|
||||
baseUrl: qd?.baseUrl || '',
|
||||
userid: '',
|
||||
password: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{qd?.authorized ? '재인증' : '인증'}
|
||||
</Button>
|
||||
{qd?.authorized && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('인증을 해제하시겠습니까?')) removeAuthMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 220,
|
||||
render: (_: unknown, row: CenterRow) => (
|
||||
<Space size={4} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
setEditTarget(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
tel: row.tel,
|
||||
manager: row.manager,
|
||||
})
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Link to={`/care/setting/fee?tab=center&targetId=${row.id}`}>
|
||||
<Button size="small" type="primary" className="branch-row-btn">
|
||||
수수료
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[query.data, qdMap, moveMut, onoffMut, removeMut, removeAuthMut, editForm, authForm],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
입력처관리
|
||||
</h2>
|
||||
<p>센터(입력처)를 등록하고 퀵전송 인증·수수료를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Form form={form} layout="inline" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '센터명' }]}>
|
||||
<Input placeholder="센터명" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tel">
|
||||
<Input placeholder="연락처" />
|
||||
</Form.Item>
|
||||
<Form.Item name="manager">
|
||||
<Input placeholder="담당자" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<DraggableModal
|
||||
title="입력처 수정"
|
||||
open={!!editTarget}
|
||||
onCancel={() => setEditTarget(null)}
|
||||
onOk={() => editForm.submit()}
|
||||
confirmLoading={saveMut.isPending}
|
||||
destroyOnClose
|
||||
width={420}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
|
||||
<Form.Item name="name" label="센터명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tel" label="연락처">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="manager" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`${authTarget?.name || ''} — ${authTarget?.mode === 'reauth' ? '재인증' : '인증'}`}
|
||||
open={!!authTarget}
|
||||
onCancel={() => setAuthTarget(null)}
|
||||
onOk={() => authForm.submit()}
|
||||
confirmLoading={authMut.isPending}
|
||||
destroyOnClose
|
||||
width={440}
|
||||
>
|
||||
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
|
||||
{authTarget?.mode === 'auth' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="상부점 기본 URL"
|
||||
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="https://upper.example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const TOGGLES: [string, string][] = [
|
||||
['order_disabled', '접수 등록 잠금(최근 2개월만)'],
|
||||
['personal_info_conceal', '개인정보 숨김'],
|
||||
['personal_info_conceal_in_order_list', '접수목록 개인정보 숨김'],
|
||||
['exceldown_button_hide', '엑셀 다운로드 버튼 숨김'],
|
||||
['gift_button_hide', '사은품 버튼 숨김'],
|
||||
]
|
||||
|
||||
const chk = (v?: string) => v === 'true'
|
||||
|
||||
export default function SettingConfigurePage() {
|
||||
const qc = useQueryClient()
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-configure'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, string>>>(
|
||||
`${apiPrefix()}/homes/configure`,
|
||||
)
|
||||
return unwrap(data) || {}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) setConfig(query.data)
|
||||
}, [query.data])
|
||||
|
||||
const set = (key: string, value: string) => setConfig((c) => ({ ...c, [key]: value }))
|
||||
const setChk = (key: string, checked: boolean) => set(key, checked ? 'true' : 'false')
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/configure`, config)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('저장되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-configure'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/configure/toggle/${key}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-configure'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
children: ReactNode,
|
||||
hint?: string,
|
||||
) => (
|
||||
<Form.Item label={label} style={{ marginBottom: 16 }}>
|
||||
{children}
|
||||
{hint && (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0', fontSize: 12 }}>
|
||||
{hint}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
기타 환경 설정
|
||||
</h2>
|
||||
<p>사이트·알림·IP·기능 토글 등 기타 설정을 관리합니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small" loading={query.isLoading} style={{ maxWidth: 900, marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
{field(
|
||||
'사이트명',
|
||||
<Input
|
||||
value={config.sitename || ''}
|
||||
onChange={(e) => set('sitename', e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'고객센터번호',
|
||||
<Input
|
||||
value={config.tel || ''}
|
||||
onChange={(e) => set('tel', e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
/>,
|
||||
'로그인 페이지에 표시될 고객센터 번호입니다.',
|
||||
)}
|
||||
{field(
|
||||
'FAX',
|
||||
<Input
|
||||
value={config.fax || ''}
|
||||
onChange={(e) => set('fax', e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'SMS 발신번호',
|
||||
<Input
|
||||
value={config.sms_callback || ''}
|
||||
onChange={(e) => set('sms_callback', e.target.value)}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>,
|
||||
'SMS 발신번호는 등록 후 사용 가능합니다.',
|
||||
)}
|
||||
{field(
|
||||
'엑셀 다운로드 이메일',
|
||||
<Input
|
||||
value={config.excel_down_email || ''}
|
||||
onChange={(e) => set('excel_down_email', e.target.value)}
|
||||
style={{ maxWidth: 280 }}
|
||||
/>,
|
||||
'설정 시 엑셀 생성 후 해당 주소로 파일이 발송됩니다.',
|
||||
)}
|
||||
{field(
|
||||
'로고 경로',
|
||||
<Input
|
||||
value={config.logo_path || ''}
|
||||
onChange={(e) => set('logo_path', e.target.value)}
|
||||
placeholder="/path/to/logo.png"
|
||||
style={{ maxWidth: 400 }}
|
||||
/>,
|
||||
'파일 업로드는 지원하지 않습니다. 로고 경로를 텍스트로 설정하세요.',
|
||||
)}
|
||||
{field(
|
||||
'회원가입 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={config.signup_sms_destno || ''}
|
||||
onChange={(e) => set('signup_sms_destno', e.target.value)}
|
||||
placeholder="전화번호 (콤마 구분)"
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<Input
|
||||
value={config.signup_sms_message || ''}
|
||||
onChange={(e) => set('signup_sms_message', e.target.value)}
|
||||
placeholder="발송 메시지 (@영업점 치환)"
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'접수등록 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={config.order_created_sms_destno || ''}
|
||||
onChange={(e) => set('order_created_sms_destno', e.target.value)}
|
||||
placeholder="전화번호 (콤마 구분)"
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<Input
|
||||
value={config.order_created_sms_message || ''}
|
||||
onChange={(e) => set('order_created_sms_message', e.target.value)}
|
||||
placeholder="발송 메시지 (@영업점 치환)"
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'승인 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
checked={chk(config.branch_granted_sms)}
|
||||
onChange={(e) => setChk('branch_granted_sms', e.target.checked)}
|
||||
>
|
||||
승인 메시지를 발송합니다.
|
||||
</Checkbox>
|
||||
<Input
|
||||
value={config.branch_granted_message || ''}
|
||||
onChange={(e) => set('branch_granted_message', e.target.value)}
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'접속 허용 IP',
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={config.ip_list || ''}
|
||||
onChange={(e) => set('ip_list', e.target.value)}
|
||||
placeholder="공백 또는 콤마로 구분"
|
||||
/>,
|
||||
'관리자 접속 시에만 적용됩니다. 비우면 제한 없음.',
|
||||
)}
|
||||
{field(
|
||||
'IP 정책 무시 아이디',
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={config.enable_ip_userid || ''}
|
||||
onChange={(e) => set('enable_ip_userid', e.target.value)}
|
||||
placeholder="공백 또는 콤마로 구분"
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'회원가입 약관',
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={config.agreement || ''}
|
||||
onChange={(e) => set('agreement', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'로그인 소스 변경',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
checked={chk(config.login_ui_apply)}
|
||||
onChange={(e) => setChk('login_ui_apply', e.target.checked)}
|
||||
>
|
||||
로그인 소스를 적용합니다.
|
||||
</Checkbox>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={config.login_ui || ''}
|
||||
onChange={(e) => set('login_ui', e.target.value)}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="SMS 게이트웨이 (nBill)" style={{ maxWidth: 900, marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
{field(
|
||||
'회사키(company_key)',
|
||||
<Input
|
||||
value={config.company_key || ''}
|
||||
onChange={(e) => set('company_key', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'관제 서버 URL(manager_url)',
|
||||
<Input
|
||||
value={config.manager_url || ''}
|
||||
onChange={(e) => set('manager_url', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="기능 토글" style={{ maxWidth: 900 }}>
|
||||
{TOGGLES.map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Button
|
||||
size="small"
|
||||
type={config[key] === 'true' ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
loading={toggleMut.isPending}
|
||||
onClick={() => toggleMut.mutate(key)}
|
||||
>
|
||||
{config[key] === 'true' ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type TabKey = 'center' | 'group' | 'branch'
|
||||
|
||||
type Target = { id: number; name: string }
|
||||
type Isp = { id: number; name?: string; telecom?: string }
|
||||
type FeeSheet = {
|
||||
id: number
|
||||
type?: string
|
||||
ispId?: number
|
||||
ispName?: string
|
||||
carrier?: string
|
||||
memo?: string
|
||||
startDate?: string
|
||||
category?: string
|
||||
}
|
||||
type FeeProduct = { id?: number; productType: string; productName: string; fee: number }
|
||||
type FeeDetail = {
|
||||
fee: {
|
||||
id: number
|
||||
type?: string
|
||||
carrier?: string
|
||||
ispName?: string
|
||||
startDate?: string
|
||||
memo?: string
|
||||
}
|
||||
products: FeeProduct[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷',
|
||||
e02: '전화',
|
||||
e03: 'TV',
|
||||
e04: '렌탈',
|
||||
}
|
||||
|
||||
async function loadTargets(tab: TabKey): Promise<Target[]> {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<Target[]>>(
|
||||
`${apiPrefix()}/homes/fee-targets/${tab}`,
|
||||
)
|
||||
const rows = unwrap(data)
|
||||
if (rows?.length) return rows
|
||||
} catch {
|
||||
/* fallback below */
|
||||
}
|
||||
if (tab === 'center') {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
|
||||
`${apiPrefix()}/homes/input-centers`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
|
||||
}
|
||||
if (tab === 'group') {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
|
||||
`${apiPrefix()}/homes/branch-groups`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
|
||||
}
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string; officeName?: string }[]>>(
|
||||
`${apiPrefix()}/homes/branches`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.officeName || r.name || `#${r.id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function listUrl(tab: TabKey, tid: number) {
|
||||
if (tab === 'center') return `${apiPrefix()}/homes/center-fees/center/${tid}`
|
||||
if (tab === 'group') return `${apiPrefix()}/homes/branch-groups/${tid}/fees`
|
||||
return `${apiPrefix()}/homes/branches/${tid}/fees`
|
||||
}
|
||||
|
||||
function detailBase(tab: TabKey) {
|
||||
if (tab === 'center') return `${apiPrefix()}/homes/center-fees`
|
||||
if (tab === 'group') return `${apiPrefix()}/homes/branch-group-fees`
|
||||
return `${apiPrefix()}/homes/branch-fees`
|
||||
}
|
||||
|
||||
export default function SettingFeePage() {
|
||||
const [sp] = useSearchParams()
|
||||
const initTab = (sp.get('tab') as TabKey) || 'center'
|
||||
const initTarget = sp.get('targetId') ? Number(sp.get('targetId')) : null
|
||||
const [tab, setTab] = useState<TabKey>(
|
||||
['center', 'group', 'branch'].includes(initTab) ? initTab : 'center',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
수수료관리
|
||||
</h2>
|
||||
<p>입력처·그룹·영업점 수수료 시트를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(k) => setTab(k as TabKey)}
|
||||
items={[
|
||||
{
|
||||
key: 'center',
|
||||
label: '센터 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="center"
|
||||
initialTargetId={initTab === 'center' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'group',
|
||||
label: '그룹 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="group"
|
||||
initialTargetId={initTab === 'group' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'branch',
|
||||
label: '개별(영업점) 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="branch"
|
||||
initialTargetId={initTab === 'branch' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeeSection({
|
||||
tab,
|
||||
initialTargetId = null,
|
||||
}: {
|
||||
tab: TabKey
|
||||
initialTargetId?: number | null
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [targetId, setTargetId] = useState<number | null>(initialTargetId)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
|
||||
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [products, setProducts] = useState<FeeProduct[]>([])
|
||||
const [createForm] = Form.useForm()
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: ['homes-fee-targets', tab],
|
||||
queryFn: () => loadTargets(tab),
|
||||
})
|
||||
|
||||
const sheetsQuery = useQuery({
|
||||
queryKey: ['homes-fee-sheets', tab, targetId],
|
||||
enabled: targetId != null,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeSheet[]>>(listUrl(tab, targetId!))
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['homes-fee-detail', tab, detailFeeId, detailType],
|
||||
enabled: !!detailFeeId && detailOpen,
|
||||
queryFn: async () => {
|
||||
const base =
|
||||
tab === 'group' || (tab === 'branch' && detailType === 'group')
|
||||
? `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const params = tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined
|
||||
const { data } = await client.get<ApiResponse<FeeDetail>>(
|
||||
`${base}/${detailFeeId}`,
|
||||
{ params },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data) return
|
||||
setMemo(detailQuery.data.fee.memo || '')
|
||||
setStartDate(detailQuery.data.fee.startDate || '')
|
||||
setProducts((detailQuery.data.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
|
||||
if (tab === 'branch') setEditable(detailQuery.data.fee.type === 'alone' || detailType === 'alone')
|
||||
else setEditable(true)
|
||||
}, [detailQuery.data, tab, detailType])
|
||||
|
||||
const ispLabel = (row: FeeSheet) => {
|
||||
if (row.ispName || row.carrier) return row.ispName || row.carrier
|
||||
if (row.ispId) return ispQuery.data?.find((i) => i.id === row.ispId)?.name || row.ispId
|
||||
return '-'
|
||||
}
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (row: FeeSheet) => {
|
||||
const isGroup = tab === 'group' || row.type === 'group'
|
||||
const base = isGroup && tab !== 'center'
|
||||
? `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const { data } = await client.delete(`${base}/${row.id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
startDate,
|
||||
memo,
|
||||
carrier: detailQuery.data?.fee.carrier || detailQuery.data?.fee.ispName,
|
||||
products: products.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: p.fee ?? 0,
|
||||
})),
|
||||
}
|
||||
const base =
|
||||
tab === 'group' || detailType === 'group'
|
||||
? tab === 'center'
|
||||
? detailBase(tab)
|
||||
: `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const { data } = await client.put(`${base}/${detailFeeId}`, body, {
|
||||
params: tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined,
|
||||
})
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-detail', tab, detailFeeId, detailType] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const isp = ispQuery.data?.find((i) => i.id === values.ispId)
|
||||
const carrier = isp?.name || isp?.telecom || String(values.carrier || '')
|
||||
const sd = values.startDate
|
||||
const startDateStr =
|
||||
sd && typeof sd === 'object' && 'format' in (sd as object)
|
||||
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
|
||||
: sd
|
||||
? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs().format('YYYY-MM-DD 00:00:00')
|
||||
const productRows = (values.products as FeeProduct[]) || []
|
||||
const body = {
|
||||
ispId: values.ispId,
|
||||
carrier,
|
||||
startDate: startDateStr,
|
||||
memo: values.memo || '',
|
||||
products: productRows.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: Number(p.fee) || 0,
|
||||
})),
|
||||
}
|
||||
const { data } = await client.post(listUrl(tab, targetId!), body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('등록되었습니다.')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openDetail = (row: FeeSheet, edit: boolean) => {
|
||||
const isGroup = row.type === 'group' || tab === 'group'
|
||||
setDetailType(isGroup ? 'group' : 'alone')
|
||||
setDetailFeeId(row.id)
|
||||
setDetailOpen(true)
|
||||
// 그룹 탭에서는 그룹 수수료 수정 가능. 개별 탭의 그룹 시트는 읽기 전용.
|
||||
setEditable(edit && (tab !== 'branch' || row.type !== 'group'))
|
||||
}
|
||||
|
||||
const sheets = useMemo(() => sheetsQuery.data || [], [sheetsQuery.data])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
style={{ minWidth: 220 }}
|
||||
placeholder={`${tab === 'center' ? '센터' : tab === 'group' ? '그룹' : '영업점'} 선택`}
|
||||
value={targetId ?? undefined}
|
||||
onChange={(v) => {
|
||||
setTargetId(v)
|
||||
setCreateOpen(false)
|
||||
setDetailOpen(false)
|
||||
}}
|
||||
options={(targetsQuery.data || []).map((t) => ({ value: t.id, label: t.name }))}
|
||||
allowClear
|
||||
/>
|
||||
{targetId != null && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({
|
||||
startDate: dayjs().startOf('day'),
|
||||
memo: '',
|
||||
products: [{ productType: 'e01', productName: '', fee: 0 }],
|
||||
})
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
새 수수료 등록
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{targetId == null ? (
|
||||
<Typography.Text type="secondary">대상을 선택하세요.</Typography.Text>
|
||||
) : (
|
||||
<CareTable
|
||||
rowKey={(r) => `${r.type || 'alone'}-${r.id}`}
|
||||
size="small"
|
||||
loading={sheetsQuery.isLoading}
|
||||
dataSource={sheets}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' as const },
|
||||
{
|
||||
title: '통신사',
|
||||
width: 120,
|
||||
render: (_: unknown, row: FeeSheet) => ispLabel(row),
|
||||
},
|
||||
{
|
||||
title: '적용일',
|
||||
dataIndex: 'startDate',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
render: (v?: string) => (v || '').slice(0, 16),
|
||||
},
|
||||
{
|
||||
title: '분류',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: FeeSheet) =>
|
||||
row.category || (row.type === 'group' ? '그룹' : tab === 'branch' ? '개별' : '-'),
|
||||
},
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '',
|
||||
width: 200,
|
||||
render: (_: unknown, row: FeeSheet) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => openDetail(row, false)}
|
||||
>
|
||||
상세
|
||||
</Button>
|
||||
{(row.type !== 'group' || tab !== 'branch') && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => openDetail(row, true)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{(row.type !== 'group' || tab !== 'branch') && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DraggableModal
|
||||
title="수수료 등록"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
okText="등록"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="ispId" label="통신사" rules={[{ required: true, message: '통신사를 선택하세요.' }]}>
|
||||
<Select
|
||||
options={(ispQuery.data || []).map((i) => ({
|
||||
value: i.id,
|
||||
label: i.name || i.telecom || `#${i.id}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="적용일" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.List name="products">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong>상품별 수수료</Typography.Text>
|
||||
<Button size="small" onClick={() => add({ productType: 'e01', productName: '', fee: 0 })}>
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }} wrap>
|
||||
<Form.Item {...field} name={[field.name, 'productType']} rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(PRODUCT_TYPE).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'productName']} rules={[{ required: true, message: '상품명' }]}>
|
||||
<Input placeholder="상품명" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'fee']}>
|
||||
<InputNumber placeholder="수수료" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Button size="small" danger onClick={() => remove(field.name)}>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`수수료 상세${detailType === 'group' ? ' (그룹)' : ''}`}
|
||||
open={detailOpen}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
footer={(
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
{editable && (
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{detailQuery.isLoading || !detailQuery.data ? (
|
||||
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">판매처 · </Typography.Text>
|
||||
{detailQuery.data.fee.carrier || detailQuery.data.fee.ispName || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">적용일 · </Typography.Text>
|
||||
{editable ? (
|
||||
<DatePicker
|
||||
showTime
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
|
||||
/>
|
||||
) : (
|
||||
detailQuery.data.fee.startDate || '-'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">메모 · </Typography.Text>
|
||||
{editable ? (
|
||||
<Input value={memo} onChange={(e) => setMemo(e.target.value)} />
|
||||
) : (
|
||||
detailQuery.data.fee.memo || '-'
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
<CareTable
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={products}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '종류',
|
||||
dataIndex: 'productType',
|
||||
width: 80,
|
||||
render: (v: string) => PRODUCT_TYPE[v] || v,
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{
|
||||
title: '수수료',
|
||||
dataIndex: 'fee',
|
||||
width: 140,
|
||||
render: (v: number, _row: FeeProduct, idx: number) =>
|
||||
editable ? (
|
||||
<InputNumber
|
||||
value={v}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(n) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p, i) => (i === idx ? { ...p, fee: Number(n) || 0 } : p)),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
`${(v ?? 0).toLocaleString('ko-KR')} 원`
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Input, Space, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type GroupRow = { id: number; name?: string; seq?: number }
|
||||
|
||||
const ROW_BTN = 'branch-row-btn isp-row-btn'
|
||||
|
||||
/** 통신사 그룹관리 팝업 */
|
||||
export default function IspGroupManageModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [editId, setEditId] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-isp-group'],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<GroupRow[]>>(`${apiPrefix()}/homes/isp-group`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-group'] })
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-group`, { name })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
message.success('추가했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp-group/${editId}`, { name: editName })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setEditId(null)
|
||||
message.success('저장했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp-group/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setName('')
|
||||
setEditId(null)
|
||||
setEditName('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableModal
|
||||
title="통신사 그룹관리"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
centered
|
||||
>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Input
|
||||
placeholder="새 그룹명"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onPressEnter={() => { if (name.trim()) addMut.mutate() }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
disabled={!name.trim()}
|
||||
loading={addMut.isPending}
|
||||
onClick={() => addMut.mutate()}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 그룹이 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '순서',
|
||||
dataIndex: 'seq',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v?: number) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '그룹명',
|
||||
dataIndex: 'name',
|
||||
render: (_: unknown, row: GroupRow) =>
|
||||
editId === row.id ? (
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onPressEnter={() => saveMut.mutate()}
|
||||
/>
|
||||
) : (
|
||||
row.name
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: GroupRow) =>
|
||||
editId === row.id ? (
|
||||
<Space>
|
||||
<Button size="small" type="primary" className={ROW_BTN} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditId(null)}>
|
||||
취소
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setEditId(row.id)
|
||||
setEditName(row.name || '')
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Input, Select, Space, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Isp = { id: number; name?: string }
|
||||
type IspLink = {
|
||||
id: number
|
||||
name?: string
|
||||
url?: string
|
||||
ispId: number
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
const empty = { name: '', url: '', ispId: 0 as number }
|
||||
|
||||
export default function SettingIspLinkPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState<{ id?: number; name: string; url: string; ispId: number }>(empty)
|
||||
|
||||
const linksQuery = useQuery({
|
||||
queryKey: ['homes-isp-link'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspLink[]>>(`${apiPrefix()}/homes/isp-link`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispName = (id: number) => ispQuery.data?.find((i) => i.id === id)?.name || id
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-link'] })
|
||||
const reset = () => setForm(empty)
|
||||
const valid = form.name.trim() && form.url.trim() && form.ispId
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = { name: form.name, url: form.url, ispId: form.ispId }
|
||||
if (form.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp-link/${form.id}`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
|
||||
reset()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp-link/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
width: 100,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Space size={4}>
|
||||
<span>{row.seq ?? '-'}</span>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '통신사',
|
||||
width: 120,
|
||||
render: (_: unknown, row: IspLink) => ispName(row.ispId),
|
||||
},
|
||||
{ title: '인증링크명', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
ellipsis: true,
|
||||
render: (v?: string) =>
|
||||
v ? (
|
||||
<a href={v} target="_blank" rel="noreferrer">
|
||||
{v}
|
||||
</a>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? '사용' : '중지'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
url: row.url || '',
|
||||
ispId: row.ispId,
|
||||
})
|
||||
}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[ispQuery.data, moveMut, onoffMut, removeMut],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
통신사인증링크
|
||||
</h2>
|
||||
<p>통신사별 인증 링크를 등록·관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
placeholder="통신사 선택"
|
||||
value={form.ispId || undefined}
|
||||
onChange={(v) => setForm({ ...form, ispId: v })}
|
||||
options={(ispQuery.data || []).map((i) => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="인증링크명"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
style={{ minWidth: 280, flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" disabled={!valid} loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
{form.id ? '수정' : '추가'}
|
||||
</Button>
|
||||
{form.id && (
|
||||
<Button onClick={reset}>
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={linksQuery.isLoading}
|
||||
dataSource={linksQuery.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 인증링크가 없습니다.' }}
|
||||
scroll={{ x: 860 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { PlusSquareOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import IspGroupManageModal from './SettingIspGroupPage'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type IspRow = {
|
||||
id: number
|
||||
telecom?: string
|
||||
name?: string
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
isSktb?: boolean
|
||||
}
|
||||
|
||||
type IspProduct = {
|
||||
id: number
|
||||
type?: string
|
||||
productType?: string
|
||||
name?: string
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type IspAgreement = {
|
||||
id: number
|
||||
name?: string
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const TELECOMS = ['SK', 'KT', 'LG']
|
||||
const PRODUCT_TYPES = [
|
||||
{ value: 'e01', label: '인터넷' },
|
||||
{ value: 'e02', label: '전화' },
|
||||
{ value: 'e03', label: 'TV' },
|
||||
{ value: 'e04', label: '렌탈' },
|
||||
]
|
||||
|
||||
const ROW_BTN = 'branch-row-btn isp-row-btn'
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
const productTypeLabel = (t?: string) =>
|
||||
PRODUCT_TYPES.find((p) => p.value === t)?.label || t || '-'
|
||||
|
||||
export default function SettingIspPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<IspRow | null>(null)
|
||||
const [productIsp, setProductIsp] = useState<IspRow | null>(null)
|
||||
const [agreementIsp, setAgreementIsp] = useState<IspRow | null>(null)
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [productForm] = Form.useForm()
|
||||
const [agreementForm] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspRow[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp'] })
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: { telecom: string; name: string }) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp/${editing.id}`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/updown/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['homes-isp-products', productIsp?.id],
|
||||
enabled: !!productIsp?.id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspProduct[]>>(
|
||||
`${apiPrefix()}/homes/isp/${productIsp!.id}/products`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const addProductMut = useMutation({
|
||||
mutationFn: async (values: { type: string; name: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${productIsp!.id}/products`, {
|
||||
type: values.type,
|
||||
productType: values.type,
|
||||
name: values.name,
|
||||
})
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('상품을 추가했습니다.')
|
||||
productForm.resetFields()
|
||||
productForm.setFieldsValue({ type: 'e01' })
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const delProductMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/products/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const agreementsQuery = useQuery({
|
||||
queryKey: ['homes-isp-agreements', agreementIsp?.id],
|
||||
enabled: !!agreementIsp?.id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspAgreement[]>>(
|
||||
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const addAgreementMut = useMutation({
|
||||
mutationFn: async (values: { name: string }) => {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('약정을 추가했습니다.')
|
||||
agreementForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const delAgreementMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/agreements/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.setFieldsValue({ telecom: 'SK', name: '' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: IspRow) => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({ telecom: row.telecom || 'SK', name: row.name })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '본사', dataIndex: 'telecom', width: 70, align: 'center' as const },
|
||||
{ title: '통신사', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '수정',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button size="small" type="primary" className={ROW_BTN} onClick={() => openEdit(row)}>
|
||||
수정
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상품관리',
|
||||
width: 88,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setProductIsp(row)
|
||||
productForm.setFieldsValue({ type: 'e01', name: '' })
|
||||
}}
|
||||
>
|
||||
상품관리
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '이벤트관리',
|
||||
width: 96,
|
||||
align: 'center' as const,
|
||||
render: () => (
|
||||
<Button size="small" disabled className="branch-row-btn">
|
||||
이벤트
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '약정관리',
|
||||
width: 88,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setAgreementIsp(row)
|
||||
agreementForm.resetFields()
|
||||
}}
|
||||
>
|
||||
약정관리
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '지사관리',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: () => (
|
||||
<Button size="small" disabled className="branch-row-btn">
|
||||
지사
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '출력순서',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'ON/OFF',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className={ROW_BTN}
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '삭제',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[moveMut, onoffMut, removeMut, productForm, agreementForm],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
통신사관리
|
||||
</h2>
|
||||
<p>통신사·상품·약정을 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<TeamOutlined />} onClick={() => setGroupOpen(true)}>
|
||||
그룹관리
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 통신사가 없습니다.' }}
|
||||
scroll={{ x: 960 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<IspGroupManageModal open={groupOpen} onClose={() => setGroupOpen(false)} />
|
||||
|
||||
<DraggableModal
|
||||
title={editing ? '통신사 수정' : '통신사 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saveMut.isPending}
|
||||
okText={editing ? '수정' : '등록'}
|
||||
destroyOnClose
|
||||
width={420}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
|
||||
<Form.Item name="telecom" label="본사" rules={[{ required: true, message: '본사를 선택하세요.' }]}>
|
||||
<Select options={TELECOMS.map((t) => ({ value: t, label: t }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="통신사명" rules={[{ required: true, message: '통신사명을 입력하세요.' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`상품관리 — ${productIsp?.name || ''}`}
|
||||
open={!!productIsp}
|
||||
onCancel={() => setProductIsp(null)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={productForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12 }}
|
||||
onFinish={(v) => addProductMut.mutate(v)}
|
||||
initialValues={{ type: 'e01' }}
|
||||
>
|
||||
<Form.Item name="type" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 110 }} options={PRODUCT_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '상품명' }]}>
|
||||
<Input placeholder="상품명" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addProductMut.isPending} className={ROW_BTN}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={productsQuery.isLoading}
|
||||
dataSource={productsQuery.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 상품이 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '종류',
|
||||
width: 80,
|
||||
render: (_: unknown, r: IspProduct) => productTypeLabel(r.productType || r.type),
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'name' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspProduct) => (isActive(r) ? '사용' : '중지'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspProduct) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) delProductMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`약정관리 — ${agreementIsp?.name || ''}`}
|
||||
open={!!agreementIsp}
|
||||
onCancel={() => setAgreementIsp(null)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={480}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={agreementForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12 }}
|
||||
onFinish={(v) => addAgreementMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '약정명' }]}>
|
||||
<Input placeholder="약정명" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addAgreementMut.isPending} className={ROW_BTN}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={agreementsQuery.isLoading}
|
||||
dataSource={agreementsQuery.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 약정이 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '약정명', dataIndex: 'name' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspAgreement) => (isActive(r) ? '사용' : '중지'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspAgreement) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) delAgreementMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type LevelsData = {
|
||||
const FALLBACK_ROLES = ['총괄', '팀장', '사원', '영업', '딜러', '개발자']
|
||||
|
||||
const FALLBACK_ROWS: Record<string, string[]> = {
|
||||
menu: ['상품관리', '계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
|
||||
menu: ['상품관리', '계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
|
||||
product: ['목록조회', '상품등록', '상품수정', '상품삭제', '정책관리', '엑셀다운로드'],
|
||||
contract: ['목록조회', '계약등록', '계약수정', '상태변경', '엑셀다운로드'],
|
||||
reception: ['목록조회', '접수등록', '접수수정', '진행변경', '엑셀다운로드'],
|
||||
@@ -65,7 +65,7 @@ function buildMenuDefaults(): RoleMatrix {
|
||||
flags[menu] = (() => {
|
||||
if (role === '총괄' || role === '팀장' || role === '개발자') return true
|
||||
if (role === '사원') {
|
||||
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
|
||||
return ['계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
|
||||
}
|
||||
if (role === '영업' || role === '딜러') {
|
||||
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리'].includes(menu)
|
||||
|
||||
@@ -53,6 +53,7 @@ const MENU_EXPOSURE = [
|
||||
{ key: 'product', label: '상품관리' },
|
||||
{ key: 'contract', label: '계약관리' },
|
||||
{ key: 'reception', label: '접수관리' },
|
||||
{ key: 'branch', label: '영업점관리' },
|
||||
{ key: 'invoice', label: '정산관리' },
|
||||
{ key: 'pending', label: '일정관리' },
|
||||
{ key: 'booking', label: '예약관리' },
|
||||
@@ -541,7 +542,7 @@ export default function SettingMemberPage() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function SettingPartner1Page() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Space>
|
||||
{editing ? (
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type QdCenter = {
|
||||
id: number
|
||||
name?: string
|
||||
authorized?: boolean
|
||||
baseUrl?: string | null
|
||||
}
|
||||
|
||||
type QdLog = {
|
||||
id: number
|
||||
createdAt?: string
|
||||
name?: string
|
||||
status?: string
|
||||
memo?: string
|
||||
orderQuickDeliveryClientId?: number
|
||||
}
|
||||
|
||||
type LogPage = {
|
||||
content?: QdLog[]
|
||||
last?: boolean
|
||||
totalElements?: number
|
||||
}
|
||||
|
||||
export default function SettingQuickDeliveryPage() {
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
퀵전송 연동
|
||||
</h2>
|
||||
<p>입력처(상부점) 인증과 진행알림 로그를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<CenterAuthSection />
|
||||
<LogSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CenterAuthSection() {
|
||||
const qc = useQueryClient()
|
||||
const [authForm] = Form.useForm()
|
||||
const [target, setTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-qd-centers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<QdCenter[]>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/centers`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
|
||||
|
||||
const authMut = useMutation({
|
||||
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
|
||||
const id = target!.id
|
||||
if (target!.mode === 'reauth') {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
|
||||
{ userid: values.userid, password: values.password },
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('완료되었습니다.')
|
||||
setTarget(null)
|
||||
authForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('인증을 해제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small" title="입력처(상부점) 인증관리" style={{ marginBottom: 16 }}>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '입력처', dataIndex: 'name' },
|
||||
{
|
||||
title: '인증상태',
|
||||
width: 100,
|
||||
render: (_: unknown, row: QdCenter) =>
|
||||
row.authorized ? (
|
||||
<Typography.Text type="success">인증됨</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">미인증</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상부점 URL',
|
||||
dataIndex: 'baseUrl',
|
||||
ellipsis: true,
|
||||
render: (v?: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 160,
|
||||
render: (_: unknown, row: QdCenter) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
setTarget({ ...row, mode: row.authorized ? 'reauth' : 'auth' })
|
||||
authForm.setFieldsValue({
|
||||
baseUrl: row.baseUrl || '',
|
||||
userid: '',
|
||||
password: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{row.authorized ? '재인증' : '인증'}
|
||||
</Button>
|
||||
{row.authorized && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('인증을 해제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DraggableModal
|
||||
title={`${target?.name || ''} — ${target?.mode === 'reauth' ? '재인증' : '인증'}`}
|
||||
open={!!target}
|
||||
onCancel={() => setTarget(null)}
|
||||
onOk={() => authForm.submit()}
|
||||
confirmLoading={authMut.isPending}
|
||||
destroyOnClose
|
||||
width={440}
|
||||
>
|
||||
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
|
||||
{target?.mode === 'auth' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="상부점 기본 URL"
|
||||
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="https://upper.example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LogSection() {
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-qd-logs', page],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<LogPage>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/logs`,
|
||||
{ params: { page, size: 50 } },
|
||||
)
|
||||
return unwrap(data) || { content: [], last: true }
|
||||
},
|
||||
})
|
||||
|
||||
const logs = query.data?.content || []
|
||||
|
||||
return (
|
||||
<Card size="small" title="진행알림 로그(상부점 → 하위점)">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={logs}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '로그가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '일시',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (v?: string) => (v || '').replace('T', ' ').substring(0, 16),
|
||||
},
|
||||
{ title: '작성자', dataIndex: 'name', width: 100 },
|
||||
{ title: '상태', dataIndex: 'status', width: 100 },
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '접수',
|
||||
dataIndex: 'orderQuickDeliveryClientId',
|
||||
width: 90,
|
||||
render: (v?: number) => (v != null ? `#${v}` : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
|
||||
이전
|
||||
</Button>
|
||||
<Typography.Text type="secondary">페이지 {page + 1}</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={query.data?.last ?? true}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, Typography } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Report = {
|
||||
lastLogin?: string | null
|
||||
lastOrderCreated?: string | null
|
||||
dailyPageViews?: { date: string; count: number }[]
|
||||
}
|
||||
|
||||
export default function SettingUsageReportPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-access-log-report'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Report>>(
|
||||
`${apiPrefix()}/homes/access-log/report`,
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString('ko-KR')
|
||||
const views = query.data?.dailyPageViews || []
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
이용현황
|
||||
</h2>
|
||||
<p>로그인·접수·페이지뷰 이용 현황을 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading && <Typography.Text type="secondary">로딩 중...</Typography.Text>}
|
||||
|
||||
{query.data && (
|
||||
<>
|
||||
<Card size="small" title="종합정보" style={{ maxWidth: 520, marginBottom: 16 }}>
|
||||
<CareTable
|
||||
size="small"
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
rowKey="k"
|
||||
dataSource={[
|
||||
{ k: 'login', label: '마지막 로그인 일자', value: query.data.lastLogin || '-' },
|
||||
{
|
||||
k: 'order',
|
||||
label: '마지막 접수 등록일자',
|
||||
value: query.data.lastOrderCreated || '-',
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{ title: '', dataIndex: 'label', width: 180 },
|
||||
{ title: '', dataIndex: 'value' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="최근 1주일 페이지뷰" style={{ maxWidth: 520 }}>
|
||||
<CareTable
|
||||
size="small"
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
rowKey="date"
|
||||
dataSource={views}
|
||||
locale={{ emptyText: '데이터가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '일자', dataIndex: 'date', width: 180 },
|
||||
{
|
||||
title: '건수',
|
||||
dataIndex: 'count',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `${fmt(v)} 건`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<Typography.Text type="danger">
|
||||
{(query.error as Error)?.message || '이용현황을 불러오지 못했습니다.'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,10 +66,24 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
|
||||
items: [
|
||||
{ label: '접수리스트', to: '/care/reception/list' },
|
||||
] },
|
||||
{
|
||||
letter: 'O',
|
||||
title: '영업점관리',
|
||||
items: [
|
||||
{ label: '영업점목록', to: '/care/branch/list' },
|
||||
] },
|
||||
{
|
||||
letter: 'D',
|
||||
title: '정산관리',
|
||||
items: [
|
||||
{ label: '가정산 리스트', to: '/care/calculate/branch?tab=prepared' },
|
||||
{ label: '정산대기 리스트', to: '/care/calculate/branch?tab=waiting' },
|
||||
{ label: '정산완료 리스트', to: '/care/calculate/branch?tab=completed' },
|
||||
{ label: '송금(환수)완료 리스트', to: '/care/calculate/branch?tab=transferred' },
|
||||
{ label: '입력처 가정산 리스트', to: '/care/calculate/center?tab=prepared' },
|
||||
{ label: '입력처 정산대기 리스트', to: '/care/calculate/center?tab=waiting' },
|
||||
{ label: '입력처 정산완료 리스트', to: '/care/calculate/center?tab=completed' },
|
||||
{ label: '입금완료 리스트', to: '/care/calculate/center?tab=transferred' },
|
||||
{ label: '정산서발행', to: '/care/invoice/publish' },
|
||||
{ label: '나의정산서', to: '/care/invoice/my' },
|
||||
{ label: '정산서이력', to: '/care/invoice/log' },
|
||||
@@ -134,6 +148,14 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
|
||||
{ label: '권한설정', to: '/care/setting/level' },
|
||||
{ label: '상부점관리', to: '/care/setting/partner1' },
|
||||
{ label: '하부점관리', to: '/care/setting/partner2' },
|
||||
{ label: '통신사관리', to: '/care/setting/isp' },
|
||||
{ label: '입력처관리', to: '/care/setting/center' },
|
||||
{ label: '통신사인증링크', to: '/care/setting/isp-link' },
|
||||
{ label: '게시판관리', to: '/care/setting/board-admin' },
|
||||
{ label: '수수료관리', to: '/care/setting/fee' },
|
||||
{ label: '퀵전송 연동', to: '/care/setting/quick-delivery' },
|
||||
{ label: '기타 환경 설정', to: '/care/setting/configure' },
|
||||
{ label: '이용현황', to: '/care/setting/usage' },
|
||||
] },
|
||||
{
|
||||
letter: 'L',
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function SmsAddressPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveGroup.isPending}>
|
||||
{editingGroup ? '수정합니다' : '등록'}
|
||||
{editingGroup ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setGroupOpen(false); setEditingGroup(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function StaffManagePage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** billing_react 호환 접수/사은품/입력처 상태 코드·라벨 */
|
||||
|
||||
export const PROGRESS_STATUS: Record<string, string> = {
|
||||
f01: '접수', f02: '재접수', f03: '접수진행중', f04: '접수완료',
|
||||
f05: '승인대기중', f06: '회신요청', f07: '접수반송', f08: '미개통오더반송',
|
||||
f09: 'TC실패반송', f10: '접수보류', f11: '접수취소', f12: '개통중',
|
||||
f13: '기사완료', f14: '개통완료', f15: '개통보류', f16: '개통취소',
|
||||
f17: '개통불가', f18: '정산보류', f19: '위면해지', f20: '직권해지',
|
||||
f21: '유보중단환수', f22: '해지', f23: '정산대기', f24: '정산완료',
|
||||
f25: '송금완료', f26: '환수정산완료', f27: '환수완료',
|
||||
f28: 'VOC(귀책)차감', f29: 'VOC(귀책)차감완료',
|
||||
}
|
||||
|
||||
export const GIFT_STATUS: Record<string, string> = {
|
||||
g01: '지급요청', g02: '지급재요청', g03: '반송', g04: '처리중',
|
||||
g05: '지급보류', g06: '지급취소', g07: '지급완료', g08: '회수요청',
|
||||
g09: '회수(반환)완료', g10: '등록완료', g11: '삭제완료', g12: '입금증요청',
|
||||
g13: '입금증완료', g14: '입금증첨부완료', g15: '입금증(센터)전달완료',
|
||||
}
|
||||
|
||||
export const CENTER_STATUS: Record<string, string> = {
|
||||
h01: '정산대기', h02: '정산완료', h03: '입금완료', h04: '정산보류',
|
||||
h05: '위면해지', h06: '직권해지', h07: '유보중단환수', h08: '해지',
|
||||
h09: '해지정산완료', h10: '해지송금완료', h11: 'VOC(귀책)송금', h12: 'VOC(귀책)송금완료',
|
||||
}
|
||||
|
||||
export const GIFT_PLACE: Record<string, string> = {
|
||||
b02: '자체발송', b01: '본사발송', b03: '본사(센터)', b04: '본사(관리자)',
|
||||
}
|
||||
|
||||
export function progressLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return PROGRESS_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function giftLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return GIFT_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function centerLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return CENTER_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function giftPlaceLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
if (code === '본사') return '본사발송'
|
||||
if (code === '영업점') return '자체발송'
|
||||
return GIFT_PLACE[code] || code
|
||||
}
|
||||
|
||||
export function statusOptions(map: Record<string, string>, allowed?: string[]) {
|
||||
const entries = Object.entries(map)
|
||||
const filtered = allowed?.length ? entries.filter(([k]) => allowed.includes(k)) : entries
|
||||
return filtered.map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
|
||||
/** 진행현황 배지 색상 */
|
||||
export function progressBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s === 'f01' || s === 'f02' || s === 'f03' || s === 'f04') return 'badge badge-ps-1'
|
||||
if (s === 'f05' || s === 'f06' || s === 'f12' || s === 'f13') return 'badge badge-ps-2'
|
||||
if (s === 'f07' || s === 'f08' || s === 'f09' || s === 'f10' || s === 'f15') return 'badge badge-ps-3'
|
||||
if (s === 'f14') return 'badge badge-ps-4'
|
||||
if (s === 'f23') return 'badge badge-ps-5'
|
||||
if (s === 'f24' || s === 'f25') return 'badge badge-ps-6'
|
||||
if (s === 'f11' || s === 'f16' || s === 'f17' || s === 'f22' || s.startsWith('f1') && Number(s.slice(1)) >= 19) return 'badge badge-ps-9'
|
||||
if (s.startsWith('f19') || s.startsWith('f2')) return 'badge badge-ps-9'
|
||||
// legacy
|
||||
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
|
||||
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
|
||||
if (s.startsWith('6.') || s === '정산완료') return 'badge badge-ps-6'
|
||||
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
|
||||
return 'badge'
|
||||
}
|
||||
|
||||
export function giftBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (!s || s === '미지급') return 'badge badge-gs-none'
|
||||
if (s === 'g01' || s === 'g02' || s === '지급대기' || s === '지급요청') return 'badge badge-gs-wait'
|
||||
if (s === 'g07' || s === '지급완료') return 'badge badge-gs-done'
|
||||
if (s === 'g03' || s === '반송') return 'badge badge-gs-return'
|
||||
if (s === 'g04' || s === 'g05') return 'badge badge-ps-3'
|
||||
return 'badge'
|
||||
}
|
||||
@@ -4252,6 +4252,23 @@ html.theme-light .reception-order-page {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.order-result { margin-left: auto; font-size: 12px; }
|
||||
.order-attach-buttons {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.order-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.order-result-row .order-result {
|
||||
margin-left: 0;
|
||||
}
|
||||
.order-search-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4456,3 +4473,356 @@ table.order-list .floating-cell:hover .floating-layer { display: block; }
|
||||
.reception-bulk-form .of-field-control textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 정산현황 가정산 */
|
||||
.calculate-page .prepared-detail-subtitle { margin: 0 0 8px; font-size: 15px; }
|
||||
.calculate-page .calc-prepared-table .ant-table-cell { font-size: 12px; padding: 4px 6px !important; vertical-align: top; }
|
||||
.calculate-page .calc-row-selected > td {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
|
||||
}
|
||||
.calculate-page button.linkish,
|
||||
.calc-fullscreen-drawer-root button.linkish,
|
||||
.calc-prepared-drawer-root button.linkish {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: none !important;
|
||||
cursor: pointer;
|
||||
color: var(--text) !important;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.calculate-page button.linkish:hover,
|
||||
.calc-fullscreen-drawer-root button.linkish:hover,
|
||||
.calc-prepared-drawer-root button.linkish:hover {
|
||||
text-decoration: underline;
|
||||
background: transparent !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
.calculate-page .summary-table-wrap .ant-table-row { cursor: pointer; }
|
||||
.calculate-page .summary-table-wrap .ant-table-row:hover > td {
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--surface)) !important;
|
||||
}
|
||||
|
||||
/* 정산 상세: 좌측패널까지 덮는 전체화면 Drawer + 테마 색상 통일 */
|
||||
.calc-fullscreen-drawer-root.ant-drawer-root .ant-drawer-content-wrapper,
|
||||
.calc-fullscreen-drawer-root .ant-drawer-content-wrapper,
|
||||
.calc-prepared-drawer-root.ant-drawer-root .ant-drawer-content-wrapper,
|
||||
.calc-prepared-drawer-root .ant-drawer-content-wrapper {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-content,
|
||||
.calc-prepared-drawer-root .ant-drawer-content,
|
||||
.calc-fullscreen-drawer-root .ant-drawer-wrapper-body,
|
||||
.calc-prepared-drawer-root .ant-drawer-wrapper-body {
|
||||
width: 100%;
|
||||
background: var(--bg) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-header,
|
||||
.calc-prepared-drawer-root .ant-drawer-header {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-title,
|
||||
.calc-prepared-drawer-root .ant-drawer-title,
|
||||
.calc-fullscreen-drawer-root .ant-drawer-close,
|
||||
.calc-prepared-drawer-root .ant-drawer-close {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-close:hover,
|
||||
.calc-prepared-drawer-root .ant-drawer-close:hover {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-body,
|
||||
.calc-prepared-drawer-root .ant-drawer-body {
|
||||
background: var(--bg) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .prepared-detail,
|
||||
.calc-fullscreen-drawer-root .prg-table,
|
||||
.calc-fullscreen-drawer-root .prg-table th,
|
||||
.calc-fullscreen-drawer-root .prg-table td,
|
||||
.calc-prepared-drawer-root .prepared-detail,
|
||||
.calc-prepared-drawer-root .prg-table,
|
||||
.calc-prepared-drawer-root .prg-table th,
|
||||
.calc-prepared-drawer-root .prg-table td {
|
||||
color: var(--text);
|
||||
}
|
||||
.calc-fullscreen-drawer-root .prg-table th,
|
||||
.calc-prepared-drawer-root .prg-table th {
|
||||
background: var(--surface-2) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .prg-table td,
|
||||
.calc-prepared-drawer-root .prg-table td {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table,
|
||||
.calc-fullscreen-drawer-root .ant-table-container,
|
||||
.calc-fullscreen-drawer-root .ant-table-thead > tr > th,
|
||||
.calc-fullscreen-drawer-root .ant-table-tbody > tr > td,
|
||||
.calc-prepared-drawer-root .ant-table,
|
||||
.calc-prepared-drawer-root .ant-table-container,
|
||||
.calc-prepared-drawer-root .ant-table-thead > tr > th,
|
||||
.calc-prepared-drawer-root .ant-table-tbody > tr > td {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-thead > tr > th,
|
||||
.calc-prepared-drawer-root .ant-table-thead > tr > th {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-thead > tr > th *,
|
||||
.calc-prepared-drawer-root .ant-table-thead > tr > th * {
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-thead > tr > th .ant-table-column-title,
|
||||
.calc-prepared-drawer-root .ant-table-thead > tr > th .ant-table-column-title {
|
||||
background: transparent !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-cell-fix-hidden,
|
||||
.calc-prepared-drawer-root .ant-table-cell-fix-hidden {
|
||||
background: var(--surface-2) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-radio-button-wrapper,
|
||||
.calc-prepared-drawer-root .ant-radio-button-wrapper {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-radio-button-wrapper-checked,
|
||||
.calc-prepared-drawer-root .ant-radio-button-wrapper-checked {
|
||||
background: color-mix(in srgb, var(--primary) 22%, var(--surface)) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-btn-default,
|
||||
.calc-prepared-drawer-root .ant-btn-default {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-item,
|
||||
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-prev .ant-pagination-item-link,
|
||||
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-next .ant-pagination-item-link,
|
||||
.calc-prepared-drawer-root .ant-pagination .ant-pagination-item,
|
||||
.calc-prepared-drawer-root .ant-pagination .ant-pagination-prev .ant-pagination-item-link,
|
||||
.calc-prepared-drawer-root .ant-pagination .ant-pagination-next .ant-pagination-item-link {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-tbody > tr.calc-row-selected > td,
|
||||
.calc-prepared-drawer-root .ant-table-tbody > tr.calc-row-selected > td {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-table-tbody > tr:hover > td,
|
||||
.calc-prepared-drawer-root .ant-table-tbody > tr:hover > td {
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--surface)) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-input,
|
||||
.calc-fullscreen-drawer-root .ant-input-number,
|
||||
.calc-fullscreen-drawer-root .ant-input-number-input,
|
||||
.calc-fullscreen-drawer-root .ant-picker,
|
||||
.calc-fullscreen-drawer-root .ant-select-selector,
|
||||
.calc-prepared-drawer-root .ant-input,
|
||||
.calc-prepared-drawer-root .ant-input-number,
|
||||
.calc-prepared-drawer-root .ant-input-number-input,
|
||||
.calc-prepared-drawer-root .ant-picker,
|
||||
.calc-prepared-drawer-root .ant-select-selector {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .muted,
|
||||
.calc-prepared-drawer-root .muted {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.calc-fullscreen-drawer-root .ant-drawer-header,
|
||||
.calc-prepared-drawer-root .ant-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .ant-drawer-header-title,
|
||||
.calc-prepared-drawer-root .ant-drawer-header-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.calc-drawer-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.calc-drawer-titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.calc-drawer-title-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .calc-drawer-back-btn,
|
||||
.calc-fullscreen-drawer-root .calc-drawer-close-btn,
|
||||
.calc-prepared-drawer-root .calc-drawer-back-btn,
|
||||
.calc-prepared-drawer-root .calc-drawer-close-btn {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.calc-fullscreen-drawer-root .calc-drawer-back-btn:hover,
|
||||
.calc-fullscreen-drawer-root .calc-drawer-close-btn:hover,
|
||||
.calc-prepared-drawer-root .calc-drawer-back-btn:hover,
|
||||
.calc-prepared-drawer-root .calc-drawer-close-btn:hover {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--surface-2)) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
/* 수수료 현황 팝업 — Drawer(z=1200) 위 + 그라데이션 타이틀바 */
|
||||
.calc-fee-status-modal.ant-modal,
|
||||
.calc-fee-alert-modal.ant-modal,
|
||||
.app-draggable-modal-root:has(.calc-fee-status-modal),
|
||||
.app-draggable-modal-root:has(.calc-fee-alert-modal) {
|
||||
z-index: 2200 !important;
|
||||
}
|
||||
.calc-fee-status-modal .app-modal-titlebar,
|
||||
.calc-fee-alert-modal .app-modal-titlebar {
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
#4a5a78 0%,
|
||||
#3a4760 28%,
|
||||
#2f3a4f 58%,
|
||||
#252e40 100%
|
||||
) !important;
|
||||
border-bottom-color: #1a2233 !important;
|
||||
}
|
||||
.calc-fee-status-modal .app-modal-title,
|
||||
.calc-fee-alert-modal .app-modal-title {
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.calc-fee-status-modal .app-modal-close,
|
||||
.calc-fee-alert-modal .app-modal-close {
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.calc-fee-status-modal .app-modal-close:hover,
|
||||
.calc-fee-alert-modal .app-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.calc-fee-status-modal .ant-modal-content,
|
||||
.calc-fee-alert-modal .ant-modal-content {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fee-alert-modal .ant-modal-header {
|
||||
background: var(--surface) !important;
|
||||
border-bottom-color: var(--border) !important;
|
||||
}
|
||||
.calc-fee-alert-modal .ant-modal-title,
|
||||
.calc-fee-alert-modal .ant-modal-close {
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fee-status-modal .ant-modal-body,
|
||||
.calc-fee-alert-modal .ant-modal-body {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fee-status-modal .prg-table th {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fee-status-modal .prg-table td {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
.calc-fee-status-modal .ant-table-thead > tr > th {
|
||||
background: var(--surface-2) !important;
|
||||
color: var(--text) !important;
|
||||
text-align: center;
|
||||
}
|
||||
.calc-fee-status-modal .ant-table-tbody > tr > td {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.calc-fee-status-modal .ant-table-tbody > tr.calc-fee-row-hit > td {
|
||||
background: color-mix(in srgb, var(--primary) 16%, var(--surface)) !important;
|
||||
}
|
||||
|
||||
/* 상세리스트 금액 InputNumber 우측정렬 */
|
||||
.calc-money-input.ant-input-number,
|
||||
.calc-prepared-table .ant-input-number {
|
||||
text-align: right;
|
||||
}
|
||||
.calc-money-input .ant-input-number-input,
|
||||
.calc-prepared-table .ant-input-number-input {
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
|
||||
.branch-list-page .ant-table-tbody > tr.row-selected > td {
|
||||
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
|
||||
}
|
||||
|
||||
.branch-list-page .branch-row-btn.ant-btn-primary,
|
||||
.branch-row-btn.ant-btn-primary {
|
||||
font-size: 12px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.branch-list-page .branch-row-btn.ant-btn-primary:not(.ant-btn-dangerous),
|
||||
.branch-row-btn.ant-btn-primary:not(.ant-btn-dangerous) {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.branch-list-page .branch-row-btn.ant-btn-primary.ant-btn-dangerous,
|
||||
.branch-row-btn.ant-btn-primary.ant-btn-dangerous {
|
||||
background: #cf1322 !important;
|
||||
border-color: #cf1322 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 모든 목록(테이블) 파란 primary 버튼 텍스트 = 좌측메뉴 active와 동일 검정 */
|
||||
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous),
|
||||
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):hover,
|
||||
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):focus,
|
||||
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):active,
|
||||
.member-expo-btn.ant-btn-primary,
|
||||
.member-expo-btn.ant-btn-primary:hover,
|
||||
.member-expo-btn.ant-btn-primary:focus {
|
||||
color: #000 !important;
|
||||
}
|
||||
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous,
|
||||
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous:hover,
|
||||
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous:focus {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user