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