Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/app/workspaces/[workspaceId]/notices/page.tsx
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} />;
}
3 changes: 3 additions & 0 deletions src/entities/notice/index.ts
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';
44 changes: 44 additions & 0 deletions src/entities/notice/model/mock-notices.ts
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:
'카드 단말기 교체가 완료되었습니다. 결제 오류가 반복되면 매니저에게 바로 공유해 주세요.',
},
];
Comment on lines +4 to +44

Copy link
Copy Markdown

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/entities/notice/model/mock-notices.ts` around lines 4 - 44, The mock
notices in mock-notices.ts are hardcoded to a single workspaceId, which will
break once workspace-aware filtering is introduced. Update the fixture so each
notice uses a configurable or per-workspace workspaceId value instead of the
fixed 'test' string, keeping submitNotice and any future workspace-scoped
consumers aligned with realistic data.

15 changes: 15 additions & 0 deletions src/entities/notice/model/notice.types.ts
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;
}
4 changes: 4 additions & 0 deletions src/features/manage-notices/index.ts
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 src/features/manage-notices/model/use-notice-board-state.ts
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)}`;
}
Comment thread
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();
}
};
Comment thread
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,
};
}
71 changes: 71 additions & 0 deletions src/features/manage-notices/ui/NoticeComposer.tsx
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 });
}}
Comment thread
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>
);
}
45 changes: 45 additions & 0 deletions src/features/manage-notices/ui/NoticeDetailPanel.tsx
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>
);
}
Loading