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
@@ -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>
)
}