From 400710997b1fc0062c5d47218190b21027fc762e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Tue, 7 Jul 2026 17:14:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B3=B5=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspaces/[workspaceId]/notices/page.tsx | 14 ++ src/entities/notice/index.ts | 3 + src/entities/notice/model/mock-notices.ts | 44 ++++++ src/entities/notice/model/notice.types.ts | 15 ++ src/features/manage-notices/index.ts | 4 + .../model/use-notice-board-state.ts | 137 ++++++++++++++++++ .../manage-notices/ui/NoticeComposer.tsx | 71 +++++++++ .../manage-notices/ui/NoticeDetailPanel.tsx | 45 ++++++ src/features/manage-notices/ui/NoticeList.tsx | 131 +++++++++++++++++ src/views/store-operation/notices/index.ts | 1 + .../notices/ui/NoticesView.tsx | 77 ++++++++++ 11 files changed, 542 insertions(+) create mode 100644 src/app/workspaces/[workspaceId]/notices/page.tsx create mode 100644 src/entities/notice/index.ts create mode 100644 src/entities/notice/model/mock-notices.ts create mode 100644 src/entities/notice/model/notice.types.ts create mode 100644 src/features/manage-notices/index.ts create mode 100644 src/features/manage-notices/model/use-notice-board-state.ts create mode 100644 src/features/manage-notices/ui/NoticeComposer.tsx create mode 100644 src/features/manage-notices/ui/NoticeDetailPanel.tsx create mode 100644 src/features/manage-notices/ui/NoticeList.tsx create mode 100644 src/views/store-operation/notices/index.ts create mode 100644 src/views/store-operation/notices/ui/NoticesView.tsx diff --git a/src/app/workspaces/[workspaceId]/notices/page.tsx b/src/app/workspaces/[workspaceId]/notices/page.tsx new file mode 100644 index 0000000..faea680 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/notices/page.tsx @@ -0,0 +1,14 @@ +// 워크스페이스 공지 페이지의 라우트 진입점입니다. +import { NoticesView } from '@/views/store-operation/notices'; + +interface NoticesPageProps { + params: Promise<{ + workspaceId: string; + }>; +} + +export default async function NoticesPage({ params }: NoticesPageProps) { + const { workspaceId } = await params; + + return ; +} diff --git a/src/entities/notice/index.ts b/src/entities/notice/index.ts new file mode 100644 index 0000000..45dbc7d --- /dev/null +++ b/src/entities/notice/index.ts @@ -0,0 +1,3 @@ +// 공지 도메인의 타입과 목업 데이터 공개 API입니다. +export type { Notice, NoticeFormValues } from './model/notice.types'; +export { mockNotices } from './model/mock-notices'; diff --git a/src/entities/notice/model/mock-notices.ts b/src/entities/notice/model/mock-notices.ts new file mode 100644 index 0000000..94c5466 --- /dev/null +++ b/src/entities/notice/model/mock-notices.ts @@ -0,0 +1,44 @@ +import type { Notice } from './notice.types'; + +export const mockNotices: Notice[] = [ + { + id: 'notice-1', + workspaceId: 'test', + title: '7월 신메뉴 출시 안내', + authorName: '김민서', + createdAt: '2025-06-28', + isPinned: true, + content: + "7월 1일부터 여름 한정 '망고 라떼'와 '피치 에이드'가 출시됩니다. 레시피 숙지 부탁드립니다.", + }, + { + id: 'notice-2', + workspaceId: 'test', + title: '주간 청소 구역 배정', + authorName: '이준혁', + createdAt: '2025-06-26', + isPinned: false, + content: + '이번 주 청소 구역 배정표를 확인해주세요. 마감 담당자는 냉장고 하단과 픽업대 주변을 추가로 점검해 주세요.', + }, + { + id: 'notice-3', + workspaceId: 'test', + title: '유니폼 교체 안내', + authorName: '김민서', + createdAt: '2025-06-24', + isPinned: false, + content: + '신규 유니폼이 입고되었습니다. 이번 주 출근 시 기존 유니폼을 반납하고 새 유니폼을 수령해 주세요.', + }, + { + id: 'notice-4', + workspaceId: 'test', + title: '카드 단말기 교체 완료', + authorName: '이준혁', + createdAt: '2025-06-22', + isPinned: false, + content: + '카드 단말기 교체가 완료되었습니다. 결제 오류가 반복되면 매니저에게 바로 공유해 주세요.', + }, +]; diff --git a/src/entities/notice/model/notice.types.ts b/src/entities/notice/model/notice.types.ts new file mode 100644 index 0000000..8fd25b8 --- /dev/null +++ b/src/entities/notice/model/notice.types.ts @@ -0,0 +1,15 @@ +// Supabase 공지 테이블 연결 전까지 화면 상태와 목업 데이터에서 공유하는 공지 형태입니다. +export interface Notice { + id: string; + workspaceId: string; + title: string; + content: string; + authorName: string; + createdAt: string; + isPinned: boolean; +} + +export interface NoticeFormValues { + title: string; + content: string; +} diff --git a/src/features/manage-notices/index.ts b/src/features/manage-notices/index.ts new file mode 100644 index 0000000..465505b --- /dev/null +++ b/src/features/manage-notices/index.ts @@ -0,0 +1,4 @@ +export { useNoticeBoardState } from './model/use-notice-board-state'; +export { NoticeComposer } from './ui/NoticeComposer'; +export { NoticeDetailPanel } from './ui/NoticeDetailPanel'; +export { NoticeList } from './ui/NoticeList'; diff --git a/src/features/manage-notices/model/use-notice-board-state.ts b/src/features/manage-notices/model/use-notice-board-state.ts new file mode 100644 index 0000000..c04b08a --- /dev/null +++ b/src/features/manage-notices/model/use-notice-board-state.ts @@ -0,0 +1,137 @@ +'use client'; + +// 워크스페이스 공지사항 게시판의 목록 정렬, 선택, 작성/수정, 삭제, 고정 상태를 관리합니다. +import { useMemo, useState } from 'react'; +import type { Notice, NoticeFormValues } from '@/entities/notice'; + +interface UseNoticeBoardStateParams { + initialNotices: Notice[]; + workspaceId: string; + authorName: string; +} + +function sortNotices(notices: Notice[]) { + return [...notices].sort((first, second) => { + if (first.isPinned !== second.isPinned) { + return first.isPinned ? -1 : 1; + } + + return second.createdAt.localeCompare(first.createdAt); + }); +} + +function createNoticeId() { + return `notice-${Date.now()}`; +} + +function createTodayLabel() { + return new Date().toISOString().slice(0, 10); +} + +export function useNoticeBoardState({ + initialNotices, + workspaceId, + authorName, +}: UseNoticeBoardStateParams) { + const [notices, setNotices] = useState(() => sortNotices(initialNotices)); + const [selectedNoticeId, setSelectedNoticeId] = useState(initialNotices[0]?.id ?? null); + const [editingNoticeId, setEditingNoticeId] = useState(null); + const [isComposerOpen, setIsComposerOpen] = useState(false); + + const sortedNotices = useMemo(() => sortNotices(notices), [notices]); + const selectedNotice = sortedNotices.find((notice) => notice.id === selectedNoticeId) ?? null; + const editingNotice = sortedNotices.find((notice) => notice.id === editingNoticeId) ?? null; + + const openCreateComposer = () => { + setEditingNoticeId(null); + setIsComposerOpen(true); + }; + + const openEditComposer = (noticeId: string) => { + setSelectedNoticeId(noticeId); + setEditingNoticeId(noticeId); + setIsComposerOpen(true); + }; + + const closeComposer = () => { + setEditingNoticeId(null); + setIsComposerOpen(false); + }; + + const submitNotice = (values: NoticeFormValues) => { + const trimmedTitle = values.title.trim(); + const trimmedContent = values.content.trim(); + + if (!trimmedTitle || !trimmedContent) { + return; + } + + if (editingNoticeId) { + setNotices((currentNotices) => + currentNotices.map((notice) => + notice.id === editingNoticeId + ? { ...notice, title: trimmedTitle, content: trimmedContent } + : notice, + ), + ); + setSelectedNoticeId(editingNoticeId); + closeComposer(); + return; + } + + const nextNotice: Notice = { + id: createNoticeId(), + workspaceId, + title: trimmedTitle, + content: trimmedContent, + authorName, + createdAt: createTodayLabel(), + isPinned: false, + }; + + setNotices((currentNotices) => sortNotices([nextNotice, ...currentNotices])); + setSelectedNoticeId(nextNotice.id); + closeComposer(); + }; + + const deleteNotice = (noticeId: string) => { + setNotices((currentNotices) => { + const nextNotices = currentNotices.filter((notice) => notice.id !== noticeId); + + if (selectedNoticeId === noticeId) { + setSelectedNoticeId(sortNotices(nextNotices)[0]?.id ?? null); + } + + return nextNotices; + }); + + if (editingNoticeId === noticeId) { + closeComposer(); + } + }; + + const togglePinned = (noticeId: string) => { + setNotices((currentNotices) => + sortNotices( + currentNotices.map((notice) => + notice.id === noticeId ? { ...notice, isPinned: !notice.isPinned } : notice, + ), + ), + ); + setSelectedNoticeId(noticeId); + }; + + return { + notices: sortedNotices, + selectedNotice, + editingNotice, + isComposerOpen, + openCreateComposer, + openEditComposer, + closeComposer, + selectNotice: setSelectedNoticeId, + submitNotice, + deleteNotice, + togglePinned, + }; +} diff --git a/src/features/manage-notices/ui/NoticeComposer.tsx b/src/features/manage-notices/ui/NoticeComposer.tsx new file mode 100644 index 0000000..ad19f1d --- /dev/null +++ b/src/features/manage-notices/ui/NoticeComposer.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useState } from 'react'; +import type { Notice, NoticeFormValues } from '@/entities/notice'; + +interface NoticeComposerProps { + editingNotice: Notice | null; + onSubmit: (values: NoticeFormValues) => void; + onCancel: () => void; +} + +export function NoticeComposer({ editingNotice, onSubmit, onCancel }: NoticeComposerProps) { + const [title, setTitle] = useState(editingNotice?.title ?? ''); + const [content, setContent] = useState(editingNotice?.content ?? ''); + + const canSubmit = title.trim().length > 0 && content.trim().length > 0; + + return ( +
{ + event.preventDefault(); + onSubmit({ title, content }); + }} + > +

+ {editingNotice ? '공지 수정' : '새 공지 작성'} +

+ +
+ + +