공지사항 메뉴 수정
This commit is contained in:
@@ -61,6 +61,7 @@ import SettingUsageReportPage from './pages/care/SettingUsageReportPage'
|
||||
import CsNoticePage from './pages/care/CsNoticePage'
|
||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||
import BbsPage from './pages/care/BbsPage'
|
||||
import AnnouncePage from './pages/care/AnnouncePage'
|
||||
import SitemapPage from './pages/care/SitemapPage'
|
||||
|
||||
function ProtectedRoute() {
|
||||
@@ -181,6 +182,7 @@ export default function App() {
|
||||
|
||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||
<Route path="bbs/notice" element={<AnnouncePage />} />
|
||||
<Route path="bbs/bbs" element={<BbsPage />} />
|
||||
|
||||
<Route path="sitemap" element={<Navigate to="/care/main/sitemap" replace />} />
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Modal, Space, Typography } from 'antd'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
|
||||
const SNOOZE_KEY = 'bizcare_announce_popup_snooze'
|
||||
|
||||
type PopupPost = {
|
||||
id: number
|
||||
title?: string
|
||||
contents?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
type SnoozeMap = Record<string, number>
|
||||
|
||||
function readSnooze(): SnoozeMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(SNOOZE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as SnoozeMap
|
||||
const now = Date.now()
|
||||
const next: SnoozeMap = {}
|
||||
for (const [id, expires] of Object.entries(parsed || {})) {
|
||||
if (typeof expires === 'number' && expires > now) next[id] = expires
|
||||
}
|
||||
return next
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeSnooze(map: SnoozeMap) {
|
||||
try {
|
||||
localStorage.setItem(SNOOZE_KEY, JSON.stringify(map))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인 후 초기화면 — 팝업 ON 공지 순차 표시 (billing_imeere POST_POPUP_LIST) */
|
||||
export default function NoticePopupQueue() {
|
||||
const [queue, setQueue] = useState<PopupPost[]>([])
|
||||
const [idx, setIdx] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<PopupPost[]>>(`${apiPrefix()}/homes/announces/popups`)
|
||||
const list = unwrap(data) || []
|
||||
if (cancelled) return
|
||||
const snooze = readSnooze()
|
||||
writeSnooze(snooze)
|
||||
const now = Date.now()
|
||||
const filtered = list.filter((p) => {
|
||||
const exp = snooze[String(p.id)]
|
||||
return !(exp && exp > now)
|
||||
})
|
||||
setQueue(filtered)
|
||||
setIdx(0)
|
||||
} catch {
|
||||
/* 팝업 실패는 무시 */
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const current = useMemo(() => queue[idx] ?? null, [queue, idx])
|
||||
const advance = () => setIdx((i) => i + 1)
|
||||
|
||||
const snooze = (hours: number) => {
|
||||
if (!current) return
|
||||
const map = readSnooze()
|
||||
map[String(current.id)] = Date.now() + hours * 60 * 60 * 1000
|
||||
writeSnooze(map)
|
||||
advance()
|
||||
}
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const body = current.contents || current.content || ''
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={current.title || '공지사항'}
|
||||
open
|
||||
onCancel={advance}
|
||||
width={560}
|
||||
centered
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space wrap style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => snooze(1)}>1시간 동안 보지 않기</Button>
|
||||
<Button onClick={() => snooze(24)}>24시간 동안 보지 않기</Button>
|
||||
<Button type="primary" onClick={advance}>
|
||||
닫기
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{body}
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -144,8 +144,11 @@ export function careMenuSections(): MenuSection[] {
|
||||
},
|
||||
{
|
||||
id: 'bbs',
|
||||
title: '사내게시판',
|
||||
items: [{ key: '/care/bbs/bbs', label: '사내게시판' }],
|
||||
title: '공지사항',
|
||||
items: [
|
||||
{ key: '/care/bbs/notice', label: '공지사항' },
|
||||
{ key: '/care/bbs/bbs', label: '사내게시판' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -194,6 +197,7 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
통계관리: 'report',
|
||||
환경설정: 'setting',
|
||||
고객센터: 'cs',
|
||||
공지사항: 'bbs',
|
||||
사내게시판: 'bbs',
|
||||
게시판: 'bbs',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
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,
|
||||
FormOutlined,
|
||||
NotificationOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import AnnouncePopupTimeFields from './AnnouncePopupTimeFields'
|
||||
import {
|
||||
buildPopupApiFields,
|
||||
defaultPopupTime,
|
||||
popupTimeFromAnnounce,
|
||||
type PopupTimeState,
|
||||
} from './announcePopupTime'
|
||||
|
||||
type AnnounceRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
authorName?: string
|
||||
views?: number
|
||||
createdDate?: string
|
||||
contents?: string
|
||||
isPopup?: number
|
||||
popupStart?: string
|
||||
popupEnd?: string
|
||||
}
|
||||
|
||||
type AnnounceResult = {
|
||||
total: number
|
||||
list: AnnounceRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function AnnouncePage() {
|
||||
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 [editing, setEditing] = useState<AnnounceRow | null>(null)
|
||||
const [detail, setDetail] = useState<AnnounceRow | null>(null)
|
||||
const [popup, setPopup] = useState<PopupTimeState>(defaultPopupTime())
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-announces', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<AnnounceResult>>(`${apiPrefix()}/homes/announces`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined,
|
||||
},
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-announces'] })
|
||||
|
||||
const closeEdit = () => {
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
setPopup(defaultPopupTime())
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
setPopup(defaultPopupTime())
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<AnnounceRow>>(`${apiPrefix()}/homes/announces/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
title: row.title,
|
||||
contents: row.contents,
|
||||
})
|
||||
setPopup(popupTimeFromAnnounce(row))
|
||||
setOpen(true)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const body = {
|
||||
title: values.title,
|
||||
contents: values.contents,
|
||||
...buildPopupApiFields(popup),
|
||||
}
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/announces/${editing.id}`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/announces`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.')
|
||||
closeEdit()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const togglePopup = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/announces/${id}/popup`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('팝업 설정을 변경했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<AnnounceRow>>(`${apiPrefix()}/homes/announces/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
refresh()
|
||||
},
|
||||
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 ?? 15),
|
||||
sort: filters.sort,
|
||||
})
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '팝업',
|
||||
dataIndex: 'isPopup',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (v: number | undefined, row: AnnounceRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={v === 1 ? 'primary' : 'default'}
|
||||
loading={togglePopup.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePopup.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
{v === 1 ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: AnnounceRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '작성자',
|
||||
dataIndex: 'authorName',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()),
|
||||
},
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '관리',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: AnnounceRow) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<FormOutlined />}
|
||||
loading={openEdit.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEdit.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[togglePopup.isPending, openEdit.isPending],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-announce-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<NotificationOutlined className="product-title-icon" />
|
||||
공지사항
|
||||
</h2>
|
||||
<p>사내 공지사항을 등록·관리합니다. 팝업 ON 항목은 로그인 후 초기화면에 표시됩니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
공지 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 15 }}
|
||||
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
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })),
|
||||
}}
|
||||
locale={{ emptyText: '등록된 공지사항이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '공지사항 수정' : '공지사항 등록'}
|
||||
open={open}
|
||||
onCancel={closeEdit}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={(values) => saveMut.mutate(values)}>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input placeholder="제목을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={10} placeholder="내용을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item label="팝업">
|
||||
<AnnouncePopupTimeFields value={popup} onChange={setPopup} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={closeEdit}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="공지사항"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={
|
||||
<Space>
|
||||
{detail ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => {
|
||||
setDetail(null)
|
||||
openEdit.mutate(detail.id)
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={() => setDetail(null)}>닫기</Button>
|
||||
</Space>
|
||||
}
|
||||
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.views ?? 0}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
<span>팝업 {detail.isPopup === 1 ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
{detail.isPopup === 1 && (
|
||||
<div className="cs-notice-meta">
|
||||
<span>
|
||||
기간 {popupTimeFromAnnounce(detail).startDate} ~ {popupTimeFromAnnounce(detail).endDate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cs-notice-body">
|
||||
{(detail.contents || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Checkbox, DatePicker, Select, Space } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
HOUR_OPTIONS,
|
||||
MIN_SEC_OPTIONS,
|
||||
type PopupTimeState,
|
||||
} from './announcePopupTime'
|
||||
|
||||
/** 팝업 체크 + 시작/종료 일시 (billing_imeere posts/create 팝업 행) */
|
||||
export default function AnnouncePopupTimeFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PopupTimeState
|
||||
onChange: (next: PopupTimeState) => void
|
||||
}) {
|
||||
const set = (patch: Partial<PopupTimeState>) => onChange({ ...value, ...patch })
|
||||
|
||||
return (
|
||||
<div className="announce-popup-fields">
|
||||
<Checkbox checked={value.isPopup} onChange={(e) => set({ isPopup: e.target.checked })}>
|
||||
팝업
|
||||
</Checkbox>
|
||||
<div className="announce-popup-time-row">
|
||||
<DatePicker
|
||||
value={value.startDate ? dayjs(value.startDate) : null}
|
||||
onChange={(d) => set({ startDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
allowClear={false}
|
||||
/>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.startHour}
|
||||
options={HOUR_OPTIONS.map((h) => ({ value: h, label: h }))}
|
||||
onChange={(v) => set({ startHour: v })}
|
||||
/>
|
||||
<span>시</span>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.startMin}
|
||||
options={MIN_SEC_OPTIONS.map((m) => ({ value: m, label: m }))}
|
||||
onChange={(v) => set({ startMin: v })}
|
||||
/>
|
||||
<span>분</span>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.startSec}
|
||||
options={MIN_SEC_OPTIONS.map((s) => ({ value: s, label: s }))}
|
||||
onChange={(v) => set({ startSec: v })}
|
||||
/>
|
||||
<span>초 부터</span>
|
||||
</div>
|
||||
<div className="announce-popup-time-row">
|
||||
<Space wrap>
|
||||
<DatePicker
|
||||
value={value.endDate ? dayjs(value.endDate) : null}
|
||||
onChange={(d) => set({ endDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
allowClear={false}
|
||||
/>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.endHour}
|
||||
options={HOUR_OPTIONS.map((h) => ({ value: h, label: h }))}
|
||||
onChange={(v) => set({ endHour: v })}
|
||||
/>
|
||||
<span>시</span>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.endMin}
|
||||
options={MIN_SEC_OPTIONS.map((m) => ({ value: m, label: m }))}
|
||||
onChange={(v) => set({ endMin: v })}
|
||||
/>
|
||||
<span>분</span>
|
||||
<Select
|
||||
className="w-70"
|
||||
value={value.endSec}
|
||||
options={MIN_SEC_OPTIONS.map((s) => ({ value: s, label: s }))}
|
||||
onChange={(v) => set({ endSec: v })}
|
||||
/>
|
||||
<span>초 까지</span>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { homeAccentColors } from '../../theme'
|
||||
import { useTheme } from '../../themeContext'
|
||||
import { money } from './homesShared'
|
||||
import NoticePopupQueue from '../../components/NoticePopupQueue'
|
||||
|
||||
type SparkBlock = {
|
||||
open?: number
|
||||
@@ -490,6 +491,8 @@ export default function HomeDashboardPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<NoticePopupQueue />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const MENU_EXPOSURE = [
|
||||
{ key: 'abooks', label: '장부관리' },
|
||||
{ key: 'report', label: '통계관리' },
|
||||
{ key: 'cs', label: '고객센터' },
|
||||
{ key: 'bbs', label: '사내게시판' },
|
||||
{ key: 'bbs', label: '공지사항' },
|
||||
{ key: 'setting', label: '환경설정' },
|
||||
]
|
||||
|
||||
|
||||
@@ -166,8 +166,11 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
|
||||
] },
|
||||
{
|
||||
letter: 'M',
|
||||
title: '게시판',
|
||||
items: [{ label: '사내게시판', to: '/care/bbs/bbs' }] },
|
||||
title: '공지사항',
|
||||
items: [
|
||||
{ label: '공지사항', to: '/care/bbs/notice' },
|
||||
{ label: '사내게시판', to: '/care/bbs/bbs' },
|
||||
] },
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 공지 팝업 노출 기간 (billing_imeere / billing_react 대응) */
|
||||
export type PopupTimeState = {
|
||||
isPopup: boolean
|
||||
startDate: string
|
||||
startHour: string
|
||||
startMin: string
|
||||
startSec: string
|
||||
endDate: string
|
||||
endHour: string
|
||||
endMin: string
|
||||
endSec: string
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
export function defaultPopupTime(): PopupTimeState {
|
||||
const now = new Date()
|
||||
const date = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`
|
||||
const h = pad2(now.getHours())
|
||||
const m = pad2(now.getMinutes())
|
||||
const s = pad2(now.getSeconds())
|
||||
return {
|
||||
isPopup: false,
|
||||
startDate: date,
|
||||
startHour: h,
|
||||
startMin: m,
|
||||
startSec: s,
|
||||
endDate: date,
|
||||
endHour: '23',
|
||||
endMin: '59',
|
||||
endSec: '59',
|
||||
}
|
||||
}
|
||||
|
||||
function splitDateTime(v?: string | null) {
|
||||
if (!v) return { date: '', hour: '00', min: '00', sec: '00' }
|
||||
const s = String(v).replace('T', ' ')
|
||||
return {
|
||||
date: s.substring(0, 10),
|
||||
hour: s.substring(11, 13) || '00',
|
||||
min: s.substring(14, 16) || '00',
|
||||
sec: s.substring(17, 19) || '00',
|
||||
}
|
||||
}
|
||||
|
||||
export function popupTimeFromAnnounce(p: {
|
||||
isPopup?: number
|
||||
popupStart?: string | null
|
||||
popupEnd?: string | null
|
||||
}): PopupTimeState {
|
||||
const st = splitDateTime(p.popupStart)
|
||||
const en = splitDateTime(p.popupEnd)
|
||||
const base = defaultPopupTime()
|
||||
return {
|
||||
isPopup: p.isPopup === 1,
|
||||
startDate: st.date || base.startDate,
|
||||
startHour: st.hour,
|
||||
startMin: st.min,
|
||||
startSec: st.sec,
|
||||
endDate: en.date || base.endDate,
|
||||
endHour: en.hour,
|
||||
endMin: en.min,
|
||||
endSec: en.sec,
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalDateTime(date: string, hour: string, min: string, sec: string) {
|
||||
return `${date}T${hour}:${min}:${sec}`
|
||||
}
|
||||
|
||||
export function buildPopupApiFields(popup: PopupTimeState) {
|
||||
return {
|
||||
isPopup: popup.isPopup ? 1 : 0,
|
||||
popupStart: popup.isPopup
|
||||
? toLocalDateTime(popup.startDate, popup.startHour, popup.startMin, popup.startSec)
|
||||
: null,
|
||||
popupEnd: popup.isPopup
|
||||
? toLocalDateTime(popup.endDate, popup.endHour, popup.endMin, popup.endSec)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => pad2(i))
|
||||
export const MIN_SEC_OPTIONS = Array.from({ length: 60 }, (_, i) => pad2(i))
|
||||
@@ -5147,3 +5147,32 @@ html.theme-light .farebox-gray-btn:not(:disabled):hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 공지사항 팝업 기간 입력 */
|
||||
.announce-popup-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.announce-popup-time-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.announce-popup-time-row .w-70 {
|
||||
width: 70px;
|
||||
}
|
||||
.homes-announce-page .cs-notice-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.homes-announce-page .cs-notice-body {
|
||||
margin-top: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user