BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user