BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Area, Column } from '@ant-design/plots'
|
||||
import { Card, Form, Input, Modal, Spin, Tabs, message } from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
CustomerServiceOutlined,
|
||||
EditOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ScheduleOutlined,
|
||||
TeamOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { homeAccentColors } from '../../theme'
|
||||
import { useTheme } from '../../themeContext'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type SparkBlock = {
|
||||
open?: number
|
||||
today?: number
|
||||
total?: number
|
||||
monthNew?: number
|
||||
monthAmount?: number
|
||||
settleMonth?: number
|
||||
spark?: number[]
|
||||
}
|
||||
|
||||
type ChartPanel = {
|
||||
title?: string
|
||||
label?: string
|
||||
offset?: number
|
||||
apply?: number
|
||||
accept?: number
|
||||
done?: number
|
||||
cancel?: number
|
||||
settleAmount?: number
|
||||
count?: number
|
||||
daily?: { day: number; value: number }[]
|
||||
}
|
||||
|
||||
type StatusBlock = {
|
||||
total?: number
|
||||
items?: Record<string, number>
|
||||
}
|
||||
|
||||
type Postit = {
|
||||
slot: number
|
||||
title?: string
|
||||
contents?: string
|
||||
authorName?: string
|
||||
noteDate?: string
|
||||
}
|
||||
|
||||
type Dash = {
|
||||
pending?: SparkBlock
|
||||
booking?: SparkBlock
|
||||
customer?: SparkBlock
|
||||
abook?: SparkBlock
|
||||
contractTotal?: number
|
||||
customerTotal?: number
|
||||
postits?: Postit[]
|
||||
internet?: ChartPanel
|
||||
home?: ChartPanel
|
||||
internetStatus?: StatusBlock
|
||||
homeStatus?: StatusBlock
|
||||
notices?: { id: number; title?: string; createdDate?: string }[]
|
||||
questions?: { id: number; title?: string; createdDate?: string }[]
|
||||
csPhone?: string
|
||||
}
|
||||
|
||||
function Wave({ color }: { color: string }) {
|
||||
return (
|
||||
<svg className="home-wave" viewBox="0 0 200 42" preserveAspectRatio="none" aria-hidden>
|
||||
<path
|
||||
d="M0 28 C 30 8, 50 38, 80 22 S 130 4, 160 24 S 190 36, 200 18 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M0 32 C 40 18, 70 36, 100 26 S 150 12, 180 28 S 195 34, 200 26 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniBars({ values, color }: { values: number[]; color: string }) {
|
||||
const max = Math.max(1, ...values)
|
||||
return (
|
||||
<div className="home-mini-bars" aria-hidden>
|
||||
{values.map((v, i) => (
|
||||
<span key={i} style={{ height: `${Math.max(12, (v / max) * 100)}%`, background: color }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeDashboardPage() {
|
||||
const { theme } = useTheme()
|
||||
const accents = useMemo(() => homeAccentColors(theme), [theme])
|
||||
const qc = useQueryClient()
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [period, setPeriod] = useState('day')
|
||||
const [editSlot, setEditSlot] = useState<Postit | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-dashboard', offset, period],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Dash>>(`${apiPrefix()}/homes/dashboard`, {
|
||||
params: { offset, period } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const savePostit = useMutation({
|
||||
mutationFn: async (values: { contents: string }) => {
|
||||
if (!editSlot) return
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/dashboard/postits/${editSlot.slot}`, {
|
||||
contents: values.contents,
|
||||
authorName: localStorage.getItem('userName') || '홍길동' })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('저장했습니다.')
|
||||
setEditSlot(null)
|
||||
qc.invalidateQueries({ queryKey: ['homes-dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="home-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = query.data || {}
|
||||
const pending = d.pending || {}
|
||||
const booking = d.booking || {}
|
||||
const customer = d.customer || {}
|
||||
const abook = d.abook || {}
|
||||
const internet = d.internet || {}
|
||||
const home = d.home || {}
|
||||
const internetStatus = d.internetStatus || {}
|
||||
const homeStatus = d.homeStatus || {}
|
||||
|
||||
const columnCfg = (daily: { day: number; value: number }[] | undefined, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
color: fill,
|
||||
columnStyle: { radius: [3, 3, 0, 0] },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } },
|
||||
tooltip: { title: (v: number) => `${v}일` } })
|
||||
|
||||
const areaCfg = (daily: { day: number; value: number }[] | undefined, stroke: string, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
smooth: true,
|
||||
color: stroke,
|
||||
areaStyle: { fill },
|
||||
line: { color: stroke, size: 2 },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } } })
|
||||
|
||||
return (
|
||||
<div className="home-dashboard">
|
||||
<div className="home-top-row">
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-stock-split">
|
||||
<Link to="/care/pending/pending" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<CalendarOutlined /> 일정관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{pending.open ?? 0} <span className="home-stock-hint">/ {pending.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">오늘 약속 {pending.today ?? 0}건</div>
|
||||
</div>
|
||||
<MiniBars values={pending.spark || [2, 3, 4, 3, 5, 4]} color={accents.sparkPhone} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
미결 <b>{pending.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
오늘 <b>{pending.today ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkPhone} />
|
||||
</Link>
|
||||
<Link to="/care/booking/booking" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<ScheduleOutlined /> 예약관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{booking.open ?? 0} <span className="home-stock-hint">/ {booking.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">접수 대기 현황</div>
|
||||
</div>
|
||||
<MiniBars values={booking.spark || [1, 2, 2, 3, 2, 1]} color={accents.sparkUsim} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
접수 <b>{booking.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
전체 <b>{booking.total ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkUsim} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-status-split">
|
||||
<Link to="/care/customer/customer" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">등록고객</div>
|
||||
<div className="home-status-value">
|
||||
{customer.total ?? d.customerTotal ?? 0}명
|
||||
</div>
|
||||
<div className="home-stock-hint">이달 신규 {customer.monthNew ?? 0}</div>
|
||||
</div>
|
||||
<TeamOutlined className="home-status-icon teal" />
|
||||
</div>
|
||||
<Wave color={accents.waveOut} />
|
||||
</Link>
|
||||
<Link to="/care/abooks/abooks-mon" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">이달장부</div>
|
||||
<div className="home-status-value">{money(abook.monthAmount)}</div>
|
||||
<div className="home-stock-hint">정산 {money(abook.settleMonth)}</div>
|
||||
</div>
|
||||
<BookOutlined className="home-status-icon purple" />
|
||||
</div>
|
||||
<Wave color={accents.waveFare} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-postit-row">
|
||||
{(d.postits || []).map((p) => (
|
||||
<div key={p.slot} className="home-postit">
|
||||
<div className="home-postit-head">
|
||||
<span>{p.title || `포스트잇 #${p.slot}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="수정"
|
||||
onClick={() => {
|
||||
setEditSlot(p)
|
||||
form.setFieldsValue({ contents: p.contents })
|
||||
}}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="home-postit-body">{p.contents || ''}</div>
|
||||
<div className="home-postit-foot">
|
||||
<span>by {p.authorName || '-'}</span>
|
||||
<span>{p.noteDate || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="home-chart-row">
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>
|
||||
<button type="button" className="home-month-nav" onClick={() => setOffset((o) => o + 1)}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
인터넷접수 (영업기준)
|
||||
<button
|
||||
type="button"
|
||||
className="home-month-nav"
|
||||
onClick={() => setOffset((o) => Math.max(0, o - 1))}
|
||||
>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{internet.label}</span>
|
||||
<span className="muted">계약 {internet.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{internet.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{internet.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{internet.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(internet.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Column {...columnCfg(internet.daily, accents.columnFill)} />
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">인터넷현황 {internetStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(internetStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[2, 3, 4, 3, Number(v) || 1, 2]} color={accents.sparkPhone} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>홈렌탈접수 (영업기준)</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{home.label}</span>
|
||||
<span className="muted">계약 {home.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{home.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{home.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{home.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(home.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Area
|
||||
{...areaCfg(
|
||||
home.daily,
|
||||
accents.columnFill,
|
||||
theme === 'light' ? 'rgba(37,99,235,0.22)' : 'rgba(122,162,247,0.28)',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">홈렌탈현황 {homeStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(homeStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[1, 2, 3, Number(v) || 1, 2, 3]} color={accents.areaStroke} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-bottom-row">
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={
|
||||
<Link to="/care/cs/notice">
|
||||
공지사항
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.notices || []).length === 0 ? (
|
||||
<li className="empty">등록된 공지가 없습니다.</li>
|
||||
) : (
|
||||
(d.notices || []).map((n) => (
|
||||
<li key={n.id}>
|
||||
<Link to="/care/cs/notice">{n.title}</Link>
|
||||
<span>{n.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={<Link to="/care/cs/question">사용문의</Link>}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.questions || []).length === 0 ? (
|
||||
<li className="empty">등록된 문의가 없습니다.</li>
|
||||
) : (
|
||||
(d.questions || []).map((q) => (
|
||||
<li key={q.id}>
|
||||
<Link to="/care/cs/question">{q.title}</Link>
|
||||
<span>{q.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-cs-card"
|
||||
size="small"
|
||||
title={
|
||||
<span>
|
||||
<CustomerServiceOutlined /> 고객센터
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="home-cs-phone">{d.csPhone || '0000-0000'}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editSlot ? `포스트잇 #${editSlot.slot}` : '포스트잇'}
|
||||
open={Boolean(editSlot)}
|
||||
onCancel={() => setEditSlot(null)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={savePostit.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => savePostit.mutate(v)}>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요' }]}>
|
||||
<Input.TextArea rows={4} maxLength={200} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user