BillCare 초기 소스 등록

비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-12 05:05:17 +09:00
commit 03cd93e1f2
160 changed files with 47915 additions and 0 deletions
+356
View File
@@ -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>
)
}