03cd93e1f2
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|