Files
BizCare/frontend/src/pages/care/HomeDashboardPage.tsx
T
Macbook 03cd93e1f2 BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:05:17 +09:00

496 lines
16 KiB
TypeScript

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