공지사항 메뉴 수정

This commit is contained in:
Macbook
2026-07-13 10:07:33 +09:00
parent af45c5dbee
commit 2d26735463
17 changed files with 1217 additions and 9 deletions
+2
View File
@@ -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>
)
}
+6 -2
View File
@@ -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',
}
+415
View File
@@ -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: '환경설정' },
]
+5 -2
View File
@@ -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))
+29
View File
@@ -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;
}