BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type PublishRow = {
|
||||
id?: number | null
|
||||
employee: string
|
||||
yearMonth: string
|
||||
internetCount?: number
|
||||
internetAmount?: number
|
||||
homeCount?: number
|
||||
homeAmount?: number
|
||||
balanceCount?: number
|
||||
balanceAmount?: number
|
||||
settleAmount?: number
|
||||
issued?: boolean
|
||||
status?: string
|
||||
consent?: string
|
||||
taxInvoice?: string
|
||||
withdrawStatus?: string
|
||||
mismatched?: boolean
|
||||
}
|
||||
|
||||
type PublishResult = {
|
||||
yearMonth: string
|
||||
total: number
|
||||
list: PublishRow[]
|
||||
}
|
||||
|
||||
const num = (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const cell = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString())
|
||||
const moneyCell = (v: unknown, mismatched?: boolean) => {
|
||||
if (num(v) === 0) return ''
|
||||
return <span className={mismatched ? 'invoice-mismatch' : undefined}>{money(v)}</span>
|
||||
}
|
||||
|
||||
export default function InvoicePublishPage() {
|
||||
const qc = useQueryClient()
|
||||
const [cursor, setCursor] = useState(() => dayjs().startOf('month'))
|
||||
const yearMonth = cursor.format('YYYY-MM')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-invoice-publish', yearMonth],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PublishResult>>(`${apiPrefix()}/homes/invoices/publish`, {
|
||||
params: { yearMonth } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: async (row: PublishRow) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/invoices/publish/issue`, {
|
||||
yearMonth: row.yearMonth || yearMonth,
|
||||
employee: row.employee })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정산서를 발행했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-invoice-publish'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '발행에 실패했습니다.') })
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/invoices/publish/export`, {
|
||||
params: { yearMonth },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `정산서발행_${yearMonth}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'employee',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (v: string, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{v}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷',
|
||||
dataIndex: 'internetCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷금액',
|
||||
dataIndex: 'internetAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '홈렌탈',
|
||||
dataIndex: 'homeCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '홈렌탈금액',
|
||||
dataIndex: 'homeAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '후정산',
|
||||
dataIndex: 'balanceCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '후정산금액',
|
||||
dataIndex: 'balanceAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산서발행',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PublishRow) => (
|
||||
row.issued ? (
|
||||
<Button type="link" size="small" className="invoice-issued-link" onClick={() => issue.mutate(row)}>
|
||||
재발행
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="invoice-issue-link"
|
||||
loading={issue.isPending}
|
||||
onClick={() => issue.mutate(row)}
|
||||
>
|
||||
발행
|
||||
</Button>
|
||||
)
|
||||
) },
|
||||
{
|
||||
title: '동의',
|
||||
dataIndex: 'consent',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '계산서',
|
||||
dataIndex: 'taxInvoice',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '출금상태',
|
||||
dataIndex: 'withdrawStatus',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
], [issue])
|
||||
|
||||
return (
|
||||
<div className="stock-page invoice-publish-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<CalculatorOutlined className="product-title-icon" />
|
||||
정산서발행
|
||||
</h2>
|
||||
<p>
|
||||
직원 및 하부점에 정산서를 발행 및 관리하실 수 있습니다. (계약 진행상태가 완료인 자료만 정산서를 발행하실 수 있습니다.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card invoice-month-card" size="small">
|
||||
<div className="invoice-month-bar">
|
||||
<div className="invoice-month-nav">
|
||||
<button type="button" aria-label="이전 달" onClick={() => setCursor((c) => c.subtract(1, 'month'))}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" aria-label="다음 달" onClick={() => setCursor((c) => c.add(1, 'month'))}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey={(row) => `${row.employee}-${row.yearMonth}`}
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
rowClassName={(row) => (row.mismatched ? 'invoice-row-mismatch' : '')}
|
||||
/>
|
||||
<div className="invoice-notes">
|
||||
<Typography.Text type="secondary">
|
||||
* 정산서발행 시 자료의 양에 따라 처리시간이 오래 걸릴 수 있습니다.
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
* 붉은색 표기는 추가/수정으로 인해 발행정산서와 일치하지 않는 내용입니다. (필요한 경우 재발행하시면 해결됩니다.)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user