BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, InputNumber, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { DownloadOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const sortFields = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명', '통신사']
|
||||
const defaultSorts = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '0' : Number(v).toLocaleString())
|
||||
|
||||
type AdjustRow = {
|
||||
id: number
|
||||
opendate?: string
|
||||
incompanyName?: string
|
||||
outcompanyName?: string
|
||||
name?: string
|
||||
openhow?: string
|
||||
pmodel?: string
|
||||
pserial?: string
|
||||
decltype?: string
|
||||
plan?: string
|
||||
sellprice?: number
|
||||
basicprice1?: number
|
||||
basicprice2?: number
|
||||
basicprice3?: number
|
||||
}
|
||||
|
||||
export default function OpenAdjustPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({})
|
||||
const [rows, setRows] = useState<AdjustRow[]>([])
|
||||
const [batch, setBatch] = useState({ basicprice1: undefined as number | undefined, basicprice2: undefined as number | undefined, basicprice3: undefined as number | undefined })
|
||||
|
||||
const companies = useQuery({ queryKey: ['adjust-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 outOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => ['SHOP', 'DEALER', 'OUT'].includes(String(c.type))).map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const query = useQuery({
|
||||
queryKey: ['open-adjust', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: AdjustRow[] }>>(`${apiPrefix()}/opens/adjust`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data?.list) {
|
||||
setRows(query.data.list.map((row) => ({
|
||||
...row,
|
||||
basicprice1: Number(row.basicprice1 ?? 0),
|
||||
basicprice2: Number(row.basicprice2 ?? 0),
|
||||
basicprice3: Number(row.basicprice3 ?? 0) })))
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => {
|
||||
if (to.diff(from, 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({ dates: [from, to] })
|
||||
}
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('개통일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (dates[1].diff(dates[0], 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
const next: Record<string, unknown> = {
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
openhow: values.openhow,
|
||||
telecom: values.telecom,
|
||||
incompanyId: values.incompanyId,
|
||||
outcompanyId: values.outcompanyId,
|
||||
salesperson: values.salesperson,
|
||||
model: values.model,
|
||||
plan: values.plan }
|
||||
for (let i = 1; i <= 7; i++) next[`sort${i}`] = values[`sort${i}`]
|
||||
setSearched(true)
|
||||
setFilters(next)
|
||||
}
|
||||
|
||||
const updateRow = (id: number, field: 'basicprice1' | 'basicprice2' | 'basicprice3', value: number | null) => {
|
||||
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, [field]: value ?? 0 } : row)))
|
||||
}
|
||||
|
||||
const applyBatch = (field: 'basicprice1' | 'basicprice2' | 'basicprice3') => {
|
||||
const value = batch[field]
|
||||
if (value == null) {
|
||||
message.warning('금액을 입력하세요.')
|
||||
return
|
||||
}
|
||||
setRows((prev) => prev.map((row) => ({ ...row, [field]: value })))
|
||||
message.success('일괄 입력했습니다.')
|
||||
}
|
||||
|
||||
const saveOne = useMutation({
|
||||
mutationFn: async (row: AdjustRow) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/opens/${row.id}`, {
|
||||
basicprice1: row.basicprice1,
|
||||
basicprice2: row.basicprice2,
|
||||
basicprice3: row.basicprice3 })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => message.success('변경했습니다.'),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveAll = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/opens/adjust/batch`, {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
basicprice1: row.basicprice1,
|
||||
basicprice2: row.basicprice2,
|
||||
basicprice3: row.basicprice3 })) })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => message.success(`${data.updated}건을 변경했습니다.`),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const download = async () => {
|
||||
if (!filters.dateFrom || !filters.dateTo) {
|
||||
message.warning('개통일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
const { data } = await client.get(`${apiPrefix()}/opens/adjust/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '일괄개통정산.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 100 },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 100, ellipsis: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 100, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'name', width: 80 },
|
||||
{ title: '종류', dataIndex: 'openhow', width: 70 },
|
||||
{ title: '모델명', dataIndex: 'pmodel', width: 140, ellipsis: true },
|
||||
{ title: '일련번호', dataIndex: 'pserial', width: 110, ellipsis: true },
|
||||
{ title: '지원방법', dataIndex: 'decltype', width: 90, render: (v: string) => v || '-' },
|
||||
{ title: '요금제', dataIndex: 'plan', width: 110, ellipsis: true },
|
||||
{ title: '정산금액', dataIndex: 'sellprice', width: 90, align: 'right' as const, render: money },
|
||||
{
|
||||
title: '기본정책',
|
||||
dataIndex: 'basicprice1',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice1}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice1', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '구두정책',
|
||||
dataIndex: 'basicprice2',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice2}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice2', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '추가정책',
|
||||
dataIndex: 'basicprice3',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice3}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice3', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '개별',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<Button size="small" onClick={() => saveOne.mutate(row)} loading={saveOne.isPending}>변경</Button>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page adjust-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>일괄개통정산</h2>
|
||||
<p>등록된 개통정보에 일괄적으로 정책금액(정산)을 변경하실 수 있습니다. (유심개통 제외)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card adjust-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '80px' }}
|
||||
initialValues={{
|
||||
sort1: '종류', sort2: '모델명', sort3: '요금제', sort4: '개통일',
|
||||
sort5: '출고처', sort6: '지원방법', sort7: '고객명' }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">개통일</span>
|
||||
<Form.Item name="dates" noStyle>
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="danger" className="adjust-hint">* 최대 31일 조회</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">검색조건</span>
|
||||
<Form.Item name="openhow" noStyle>
|
||||
<Select allowClear placeholder="-종류-" className="w-110" options={options(['신규', '번호이동', '보상', '기변', '메이징', '가개통', '선불개통'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" noStyle>
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId" noStyle>
|
||||
<Select allowClear placeholder="-입고처-" className="w-130" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyId" noStyle>
|
||||
<Select allowClear placeholder="-출고처-" className="w-130" options={outOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="salesperson" noStyle>
|
||||
<Select allowClear placeholder="-영업사원-" className="w-120" options={options(['김영업', '이영업'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" noStyle>
|
||||
<Input placeholder="-모델명-" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="plan" noStyle>
|
||||
<Input placeholder="-요금제-" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row adjust-sort-row">
|
||||
<span className="adjust-label">정렬순서</span>
|
||||
{defaultSorts.map((_, index) => (
|
||||
<span key={index} className="adjust-sort-item">
|
||||
{index > 0 && <RightOutlined className="adjust-sort-arrow" />}
|
||||
<Form.Item name={`sort${index + 1}`} noStyle>
|
||||
<Select className="w-110" options={sortFields.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="adjust-search-action">
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />} size="large">개통정보 검색</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<Card className="stock-table-card adjust-result-card" size="small">
|
||||
<div className="adjust-result-head">
|
||||
<Typography.Text>검색된 개통 <b>{query.data?.total ?? rows.length}</b> 건</Typography.Text>
|
||||
<Space>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
<Button type="link" disabled>엑셀대량처리</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="adjust-batch-bar">
|
||||
<span className="adjust-batch-guide">원하시는 금액을 입력 후 엔터키를 눌러주세요.</span>
|
||||
<Space>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="기본정책"
|
||||
value={batch.basicprice1}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice1: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice1')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice1')}>일괄입력</Button>}
|
||||
/>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="구두정책"
|
||||
value={batch.basicprice2}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice2: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice2')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice2')}>일괄입력</Button>}
|
||||
/>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="추가정책"
|
||||
value={batch.basicprice3}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice3: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice3')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice3')}>일괄입력</Button>}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
|
||||
<div className="adjust-submit">
|
||||
<Button
|
||||
className="adjust-submit-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={saveAll.isPending}
|
||||
disabled={!rows.length}
|
||||
onClick={() => saveAll.mutate()}
|
||||
>
|
||||
상기 입력된 값으로 모두 변경합니다.
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user