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
+318
View File
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react'
import Modal from '../../components/DraggableModal'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Form,
Input,
Select,
Space,
Typography,
message } from 'antd'
import CareTable from '../../components/CareTable'
import {
EditOutlined,
PlusSquareOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SearchOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type QuestionRow = {
id: number
no?: number
title?: string
answer?: string
status?: string
authorName?: string
createdDate?: string
content?: string
reply?: string
}
type QuestionResult = {
total: number
list: QuestionRow[]
}
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
export default function CsQuestionPage() {
const qc = useQueryClient()
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<QuestionRow | null>(null)
const query = useQuery({
queryKey: ['homes-questions', filters],
queryFn: async () => {
const { data } = await client.get<ApiResponse<QuestionResult>>(`${apiPrefix()}/homes/questions`, {
params: {
...filters,
q: filters.keyword || undefined } })
return unwrap(data)
} })
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-questions'] })
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const { data } = await client.post(`${apiPrefix()}/homes/questions`, {
title: values.title,
content: values.content,
status: '접수' })
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('사용문의를 등록했습니다.')
setOpen(false)
editForm.resetFields()
refresh()
},
onError: (e: Error) => message.error(e.message) })
const openDetail = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.get<ApiResponse<QuestionRow>>(`${apiPrefix()}/homes/questions/${id}`)
return unwrap(data)
},
onSuccess: (row) => setDetail(row),
onError: (e: Error) => message.error(e.message) })
const onSearch = (values: Record<string, unknown>) => {
setFilters({
...initial,
searchType: values.searchType || '제목',
keyword: values.keyword || undefined,
page: 0,
size: Number(values.size ?? filters.size ?? 10),
sort: filters.sort })
}
const onReset = () => {
form.resetFields()
form.setFieldsValue({ searchType: '제목', size: 10 })
setFilters(initial)
}
const columns = useMemo(
() => [
{
title: '번호',
dataIndex: 'no',
width: 90,
align: 'center' as const,
sorter: true },
{
title: '제목',
dataIndex: 'title',
ellipsis: true,
render: (v: string, row: QuestionRow) => (
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
{v}
</Button>
) },
{
title: '답변',
dataIndex: 'answer',
width: 120,
align: 'center' as const,
sorter: true,
render: (v?: string) => v || '' },
{
title: '작성자',
dataIndex: 'authorName',
width: 100,
align: 'center' as const,
sorter: true },
{
title: '등록일',
dataIndex: 'createdDate',
width: 120,
align: 'center' as const,
sorter: true },
],
[],
)
return (
<div className="stock-page cs-notice-page homes-cs-question-page">
<div className="stock-title">
<div>
<h2>
<QuestionCircleOutlined className="product-title-icon" />
</h2>
<p> . .</p>
</div>
<Button
className="cs-question-register-btn"
type="primary"
icon={<PlusSquareOutlined />}
onClick={() => {
editForm.resetFields()
setOpen(true)
}}
>
</Button>
</div>
<Card className="stock-filter-card" size="small">
<Form
form={form}
layout="inline"
className="farebox-filter"
initialValues={{ searchType: '제목', size: 10 }}
onFinish={onSearch}
>
<div className="farebox-filter-row">
<Form.Item name="searchType">
<Select
className="w-110"
options={['제목', '작성자', '내용', '답변'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="keyword">
<Input className="w-230" allowClear />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button icon={<ReloadOutlined />} onClick={onReset}>
</Button>
</Space>
</Form.Item>
<Form.Item name="size" className="farebox-size">
<Select
className="w-150"
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
onChange={(size) => {
form.setFieldValue('size', size)
setFilters((prev) => ({ ...prev, size, page: 0 }))
}}
/>
</Form.Item>
</div>
</Form>
</Card>
<Card className="stock-table-card" size="small">
<div className="farebox-table-toolbar">
<Typography.Text>
<b>{query.data?.total ?? 0}</b>
</Typography.Text>
</div>
<CareTable
size="small"
loading={query.isLoading}
rowKey="id"
dataSource={query.data?.list}
columns={columns}
bordered
pagination={{
current: Number(filters.page ?? 0) + 1,
pageSize: Number(filters.size ?? 10),
total: query.data?.total ?? 0,
showSizeChanger: false,
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
onChange={(_p, _f, sorter) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter
if (!s?.field || !s.order) return
setFilters((prev) => ({
...prev,
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}`,
page: 0 }))
}}
locale={{ emptyText: '등록되어 있는 사용문의가 없습니다.' }}
/>
</Card>
<Modal
title="사용문의 등록"
open={open}
onCancel={() => setOpen(false)}
footer={null}
width={560}
destroyOnHidden
centered
>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => createMut.mutate(values)}
>
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
<Input placeholder="제목을 입력하세요" />
</Form.Item>
<Form.Item name="content" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
<Input.TextArea rows={8} placeholder="문의 내용을 입력하세요" />
</Form.Item>
<div className="sms-send-footer">
<Button
className="cs-question-register-btn"
type="primary"
htmlType="submit"
loading={createMut.isPending}
icon={<EditOutlined />}
>
</Button>
<Button onClick={() => setOpen(false)}></Button>
</div>
</Form>
</Modal>
<Modal
title="사용문의"
open={Boolean(detail)}
onCancel={() => setDetail(null)}
footer={<Button onClick={() => setDetail(null)}></Button>}
width={640}
destroyOnHidden
centered
>
{detail ? (
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
{detail.title}
</Typography.Title>
<div className="cs-notice-meta">
<span> {detail.authorName || '-'}</span>
<span> {detail.createdDate || '-'}</span>
<span>{detail.answer || '미답변'}</span>
</div>
<div className="cs-inquiry-section">
<Typography.Text strong></Typography.Text>
<div className="cs-notice-body">
{(detail.content || '')
.split('\n')
.map((line, idx) => (
<p key={idx}>{line || '\u00A0'}</p>
))}
</div>
</div>
{detail.reply ? (
<div className="cs-inquiry-section">
<Typography.Text strong></Typography.Text>
<div className="cs-notice-body">
{detail.reply
.split('\n')
.map((line, idx) => (
<p key={idx}>{line || '\u00A0'}</p>
))}
</div>
</div>
) : null}
</div>
) : null}
</Modal>
</div>
)
}