-
Notifications
You must be signed in to change notification settings - Fork 3
feat: 워크스페이스 공지 기능 구현 (#19) #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <NoticesView workspaceId={workspaceId} />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| // 공지 도메인의 타입과 목업 데이터 공개 API입니다. | ||
| export type { Notice, NoticeFormValues } from './model/notice.types'; | ||
| export { mockNotices } from './model/mock-notices'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
| '카드 단말기 교체가 완료되었습니다. 결제 오류가 반복되면 매니저에게 바로 공유해 주세요.', | ||
| }, | ||
| ]; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
140 changes: 140 additions & 0 deletions
140
src/features/manage-notices/model/use-notice-board-state.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| 'use client'; | ||
|
|
||
| // 워크스페이스 공지사항 게시판의 목록 정렬, 선택, 작성/수정, 삭제, 고정 상태를 관리합니다. | ||
| import { 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() { | ||
| if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { | ||
| return `notice-${crypto.randomUUID()}`; | ||
| } | ||
|
|
||
| return `notice-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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( | ||
| () => sortNotices(initialNotices)[0]?.id ?? null, | ||
| ); | ||
| const [editingNoticeId, setEditingNoticeId] = useState<string | null>(null); | ||
| const [isComposerOpen, setIsComposerOpen] = useState(false); | ||
|
|
||
| const selectedNotice = notices.find((notice) => notice.id === selectedNoticeId) ?? null; | ||
| const editingNotice = notices.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) => { | ||
| const nextNotices = sortNotices(notices.filter((notice) => notice.id !== noticeId)); | ||
|
|
||
| setNotices(nextNotices); | ||
|
|
||
| if (selectedNoticeId === noticeId) { | ||
| setSelectedNoticeId(nextNotices[0]?.id ?? null); | ||
| } | ||
|
|
||
| if (editingNoticeId === noticeId) { | ||
| closeComposer(); | ||
| } | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const togglePinned = (noticeId: string) => { | ||
| setNotices((currentNotices) => | ||
| sortNotices( | ||
| currentNotices.map((notice) => | ||
| notice.id === noticeId ? { ...notice, isPinned: !notice.isPinned } : notice, | ||
| ), | ||
| ), | ||
| ); | ||
| setSelectedNoticeId(noticeId); | ||
| }; | ||
|
|
||
| return { | ||
| notices, | ||
| selectedNotice, | ||
| editingNotice, | ||
| isComposerOpen, | ||
| openCreateComposer, | ||
| openEditComposer, | ||
| closeComposer, | ||
| selectNotice: setSelectedNoticeId, | ||
| submitNotice, | ||
| deleteNotice, | ||
| togglePinned, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <form | ||
| className="rounded-2xl border border-indigo-200 bg-white p-6 shadow-sm" | ||
| onSubmit={(event) => { | ||
| event.preventDefault(); | ||
| onSubmit({ title, content }); | ||
| }} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| > | ||
| <h2 className="text-base font-bold text-slate-950"> | ||
| {editingNotice ? '공지 수정' : '새 공지 작성'} | ||
| </h2> | ||
|
|
||
| <div className="mt-4 space-y-4"> | ||
| <label className="block"> | ||
| <span className="sr-only">공지 제목</span> | ||
| <input | ||
| value={title} | ||
| onChange={(event) => setTitle(event.target.value)} | ||
| placeholder="제목을 입력하세요" | ||
| className="h-14 w-full rounded-2xl bg-slate-100 px-5 text-base font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300" | ||
| /> | ||
| </label> | ||
|
|
||
| <label className="block"> | ||
| <span className="sr-only">공지 내용</span> | ||
| <textarea | ||
| value={content} | ||
| onChange={(event) => setContent(event.target.value)} | ||
| placeholder="내용을 입력하세요" | ||
| rows={4} | ||
| className="min-h-28 w-full resize-none rounded-2xl bg-slate-100 px-5 py-4 text-base font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300" | ||
| /> | ||
| </label> | ||
| </div> | ||
|
|
||
| <div className="mt-5 flex items-center gap-2"> | ||
| <button | ||
| type="submit" | ||
| disabled={!canSubmit} | ||
| className="h-11 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50" | ||
| > | ||
| {editingNotice ? '저장' : '등록'} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={onCancel} | ||
| className="h-11 rounded-2xl bg-slate-100 px-5 text-sm font-bold text-slate-700 hover:bg-slate-200" | ||
| > | ||
| 취소 | ||
| </button> | ||
| </div> | ||
| </form> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { Star } from 'lucide-react'; | ||
| import type { Notice } from '@/entities/notice'; | ||
|
|
||
| interface NoticeDetailPanelProps { | ||
| notice: Notice | null; | ||
| } | ||
|
|
||
| export function NoticeDetailPanel({ notice }: NoticeDetailPanelProps) { | ||
| if (!notice) { | ||
| return ( | ||
| <aside className="rounded-2xl border border-dashed border-slate-300 bg-white/60 p-6 text-sm font-medium text-slate-500"> | ||
| 공지를 선택하면 내용이 표시됩니다. | ||
| </aside> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <aside className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <div className="min-w-0"> | ||
| <div className="flex items-center gap-2"> | ||
| {notice.isPinned ? ( | ||
| <Star | ||
| className="h-5 w-5 shrink-0 fill-amber-100 text-amber-500" | ||
| aria-hidden="true" | ||
| /> | ||
| ) : null} | ||
| <h2 className="text-xl font-bold leading-7 text-slate-950">{notice.title}</h2> | ||
| </div> | ||
| <p className="mt-2 text-sm font-medium text-indigo-400"> | ||
| {notice.authorName} · {notice.createdAt} | ||
| </p> | ||
| </div> | ||
|
|
||
| {notice.isPinned ? ( | ||
| <span className="shrink-0 rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-orange-600"> | ||
| 고정 | ||
| </span> | ||
| ) : null} | ||
| </div> | ||
|
|
||
| <p className="mt-6 whitespace-pre-line text-base leading-7 text-slate-800">{notice.content}</p> | ||
| </aside> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value
mock 데이터의 workspaceId가 모두 'test'로 고정됨
실제 라우트에서는 동적
workspaceId가 전달되지만(submitNotice에서 신규 공지 생성 시 실제 workspaceId 사용), mock 데이터는 항상'test'로 고정되어 있습니다. 현재는 workspaceId로 필터링하는 로직이 없어 문제되지 않지만, 추후 Supabase 연동 시 workspaceId 필터링이 추가되면 이 mock fixture가 깨질 수 있습니다.🤖 Prompt for AI Agents