Files
BillCare/frontend/src/pages/care/OpenUnitCostPage.tsx
T
Macbook 03cd93e1f2 BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:05:17 +09:00

275 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react'
import Modal from '../../components/DraggableModal'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Radio,
Select,
Space,
Typography,
message } from 'antd'
import CareTable from '../../components/CareTable'
import {
EditOutlined, PlusOutlined, QuestionCircleOutlined, TableOutlined, DollarOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import { resourceApi } from '../../api/resources'
const planGroups = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const
type UnitCost = Record<string, unknown> & {
id: number
telecom?: string
incompanyName?: string
memo?: string
category?: string
policyCount?: number
planGroupA?: number | null
planGroupB?: number | null
planGroupC?: number | null
planGroupD?: number | null
planGroupE?: number | null
planGroupF?: number | null
planGroupG?: number | null
}
function planCount(row: UnitCost, group: string) {
return row[`planGroup${group}` as keyof UnitCost] as number | null | undefined
}
function PlanGroupCell({ value }: { value?: number | null }) {
if (value == null) return <span className="unitcost-unused">- 미사용 -</span>
if (value === 0) return <Button type="link" size="small" icon={<PlusOutlined />} />
return <span>{value}</span>
}
export default function OpenUnitCostPage() {
const navigate = useNavigate()
const clientQuery = useQueryClient()
const [form] = Form.useForm()
const [policyForm] = Form.useForm()
const [editing, setEditing] = useState<UnitCost | null>(null)
const [creating, setCreating] = useState(false)
const [policyTarget, setPolicyTarget] = useState<UnitCost | null>(null)
const companies = useQuery({ queryKey: ['unitcost-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
const inOptions = useMemo(
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
[companies.data],
)
const query = useQuery({
queryKey: ['unitcosts'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ total: number; list: UnitCost[]; max: number }>>(`${apiPrefix()}/unitcosts`)
return unwrap(data)
} })
const refresh = () => clientQuery.invalidateQueries({ queryKey: ['unitcosts'] })
const policies = useQuery({
queryKey: ['unitcost-policies', policyTarget?.id],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ list: Record<string, unknown>[] }>>(`${apiPrefix()}/unitcosts/${policyTarget!.id}/policies`)
return unwrap(data)
},
enabled: Boolean(policyTarget) })
const save = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const payload: Record<string, unknown> = {
...values,
planGroupA: Number(values.planGroupCount ?? 1),
planGroupB: 0,
planGroupC: 0,
planGroupD: 0,
incompanyName: inOptions.find((o) => o.value === values.incompanyId)?.label }
delete payload.planGroupCount
if (editing) {
const { data } = await client.put(`${apiPrefix()}/unitcosts/${editing.id}`, payload)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(`${apiPrefix()}/unitcosts`, payload)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success(editing ? '수정했습니다.' : '단가표를 생성했습니다.')
setCreating(false)
setEditing(null)
form.resetFields()
refresh()
},
onError: (e: Error) => message.error(e.message) })
const addPolicy = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const { data } = await client.post(`${apiPrefix()}/unitcosts/${policyTarget!.id}/policies`, values)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('정책단가를 등록했습니다.')
policyForm.resetFields()
clientQuery.invalidateQueries({ queryKey: ['unitcost-policies', policyTarget?.id] })
refresh()
},
onError: (e: Error) => message.error(e.message) })
const openCreate = () => {
setEditing(null)
setCreating(true)
form.setFieldsValue({ category: '표준', planGroupCount: 1, telecom: undefined, memo: undefined, incompanyId: undefined })
}
const openEdit = (row: UnitCost) => {
setCreating(false)
setEditing(row)
form.setFieldsValue({
telecom: row.telecom,
memo: row.memo,
incompanyId: row.incompanyId,
category: row.category || '표준',
planGroupCount: row.planGroupA || 1 })
}
const columns = [
{ title: <Checkbox />, width: 42, align: 'center' as const, render: () => <Checkbox /> },
{ title: '통신사', dataIndex: 'telecom', width: 80, align: 'center' as const },
{ title: '입고처', dataIndex: 'incompanyName', width: 100, align: 'center' as const },
...planGroups.map((g) => ({
title: `요금제${g}군`,
key: `planGroup${g}`,
width: 90,
align: 'center' as const,
render: (_: unknown, row: UnitCost) => <PlanGroupCell value={planCount(row, g)} /> })),
{
title: '정책단가',
dataIndex: 'policyCount',
width: 120,
align: 'center' as const,
render: (count: number, row: UnitCost) => (
<Button size="small" onClick={() => setPolicyTarget(row)}>
{count > 0 ? `정책단가 ${count}개` : '정책단가 작성'}
</Button>
) },
{ title: '비고', dataIndex: 'memo', width: 160, ellipsis: true },
{
title: '정책적용',
width: 140,
align: 'center' as const,
render: (_: unknown, row: UnitCost) => (
<Button type="primary" size="small" icon={<DollarOutlined />} onClick={() => navigate(`/care/open/unitcost-apply/${row.id}`)}>
개통정책적용
</Button>
) },
{
title: '관리',
width: 60,
align: 'center' as const,
render: (_: unknown, row: UnitCost) => (
<Button type="text" size="small" icon={<EditOutlined style={{ color: '#1677ff' }} />} onClick={() => openEdit(row)} />
) },
]
return (
<div className="stock-page unitcost-page">
<div className="stock-title">
<div>
<h2><TableOutlined style={{ marginRight: 8 }} />정책단가표</h2>
<p>통신3사의 정책단가표를 작성하여 기본정책금액을 일괄적으로 적용하실 있습니다.</p>
</div>
<Space>
<Button icon={<QuestionCircleOutlined />} onClick={() => message.info('단가표를 생성한 뒤 정책단가를 작성하고, 개통정책적용으로 일괄 반영하세요.')}>사용방법</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>단가표 생성</Button>
</Space>
</div>
<Card className="stock-table-card" size="small">
<div className="unitcost-head">
<Typography.Text>등록된 단가표 <b>{query.data?.total ?? 0}</b></Typography.Text>
<Typography.Text type="danger" className="unitcost-note">* 단가표는 최대 20 등록이 가능하며 정책단가를 수정해서 사용해주세요.</Typography.Text>
</div>
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data?.list}
columns={columns}
pagination={false}
scroll={{ x: 1400 }}
/>
</Card>
<Modal
title={editing ? '단가표 수정' : '단가표 생성'}
open={creating || Boolean(editing)}
onCancel={() => { setCreating(false); setEditing(null) }}
footer={null}
destroyOnHidden
width={480}
>
<Form form={form} layout="horizontal" labelCol={{ flex: '110px' }} onFinish={(v) => save.mutate(v)} className="unitcost-form">
<Form.Item name="telecom" label="통신사" rules={[{ required: true, message: '통신사를 선택하세요' }]}
extra={<span className="unitcost-extra">* 통신 3사만 지원합니다.</span>}>
<Select placeholder="- 선택 -" options={['SKT', 'KT', 'LGU+'].map((v) => ({ value: v, label: v }))} disabled={Boolean(editing)} />
</Form.Item>
<Form.Item name="planGroupCount" label="요금제군" rules={[{ required: true, message: '요금제군을 선택하세요' }]}>
<Select placeholder="- 선택 -" options={[1, 2, 3, 4, 5, 6, 7].map((v) => ({ value: v, label: `${v}개` }))} />
</Form.Item>
<Form.Item name="memo" label="비고"><Input /></Form.Item>
<Form.Item name="incompanyId" label="입고처"
extra={<span className="unitcost-extra">* 지정입고처의 개통만 적용할 경우 선택해주세요.</span>}>
<Select allowClear placeholder="- 선택 -" options={inOptions} />
</Form.Item>
<Form.Item name="category" label="단가표구분" rules={[{ required: true }]}>
<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 onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
</Form>
</Modal>
<Modal
title={`정책단가 작성 - ${policyTarget?.telecom ?? ''} (${policyTarget?.incompanyName ?? ''})`}
open={Boolean(policyTarget)}
onCancel={() => setPolicyTarget(null)}
footer={null}
width={720}
destroyOnHidden
>
<Form form={policyForm} layout="inline" className="unitcost-policy-form" onFinish={(v) => addPolicy.mutate(v)}
initialValues={{ planGroup: 'A', amount: 100000 }}>
<Form.Item name="planGroup" label="군" rules={[{ required: true }]}>
<Select className="w-100" options={planGroups.map((g) => ({ value: g, label: `${g}군` }))} />
</Form.Item>
<Form.Item name="model" label="모델"><Input placeholder="모델명 포함" className="w-130" /></Form.Item>
<Form.Item name="planName" label="요금제"><Input placeholder="요금제" className="w-130" /></Form.Item>
<Form.Item name="amount" label="금액" rules={[{ required: true }]}>
<InputNumber className="w-130" controls={false} addonAfter="원" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={addPolicy.isPending}>추가</Button>
</Form>
<CareTable
className="unitcost-policy-table"
rowKey="id"
size="small"
loading={policies.isLoading}
dataSource={policies.data?.list}
pagination={false}
columns={[
{ title: '군', dataIndex: 'planGroup', width: 60 },
{ title: '모델', dataIndex: 'model' },
{ title: '요금제', dataIndex: 'planName' },
{ title: '금액', dataIndex: 'amount', align: 'right' as const, render: (v: number) => Number(v ?? 0).toLocaleString() },
]}
/>
</Modal>
</div>
)
}