From 783a0b70a1cd127cbf26623144ad37fae960f95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 13 Jul 2026 18:10:30 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=EA=B3=B5=EC=A7=80=20DB=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EA=B6=8C=ED=95=9C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspaces/[workspaceId]/notices/page.tsx | 4 +- src/entities/notice/api/get-notice-board.ts | 84 +++++++++ src/entities/notice/api/notice-actions.ts | 164 +++++++++++++++++ src/entities/notice/index.ts | 5 +- src/entities/notice/model/mock-notices.ts | 44 ----- src/entities/notice/model/notice-query.ts | 2 + src/entities/notice/model/notice.types.ts | 14 +- .../model/use-notice-board-state.ts | 168 +++++++++--------- .../manage-notices/ui/NoticeComposer.tsx | 14 +- src/features/manage-notices/ui/NoticeList.tsx | 118 ++++++------ src/views/dashboard/config/widget-catalog.tsx | 2 +- .../notices/ui/NoticesView.tsx | 13 +- .../ui/RecentNotices.tsx | 66 +++++-- .../20260713010000_secure_announcements.sql | 80 +++++++++ 14 files changed, 576 insertions(+), 202 deletions(-) create mode 100644 src/entities/notice/api/get-notice-board.ts create mode 100644 src/entities/notice/api/notice-actions.ts delete mode 100644 src/entities/notice/model/mock-notices.ts create mode 100644 src/entities/notice/model/notice-query.ts create mode 100644 supabase/migrations/20260713010000_secure_announcements.sql diff --git a/src/app/workspaces/[workspaceId]/notices/page.tsx b/src/app/workspaces/[workspaceId]/notices/page.tsx index faea680..8e069b9 100644 --- a/src/app/workspaces/[workspaceId]/notices/page.tsx +++ b/src/app/workspaces/[workspaceId]/notices/page.tsx @@ -1,4 +1,5 @@ // 워크스페이스 공지 페이지의 라우트 진입점입니다. +import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; import { NoticesView } from '@/views/store-operation/notices'; interface NoticesPageProps { @@ -9,6 +10,7 @@ interface NoticesPageProps { export default async function NoticesPage({ params }: NoticesPageProps) { const { workspaceId } = await params; + const initialData = await getNoticeBoard(workspaceId); - return ; + return ; } diff --git a/src/entities/notice/api/get-notice-board.ts b/src/entities/notice/api/get-notice-board.ts new file mode 100644 index 0000000..633d386 --- /dev/null +++ b/src/entities/notice/api/get-notice-board.ts @@ -0,0 +1,84 @@ +'use server'; + +// 워크스페이스 공지와 현재 사용자의 역할을 함께 조회해 공지 화면·위젯의 데이터 기준을 통일합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { Notice, NoticeBoardData } from '../model/notice.types'; + +const workspaceIdSchema = z.guid(); + +function toCreatedAtLabel(value: string): string { + return value.slice(0, 10); +} + +export async function getNoticeBoard(workspaceId: string): Promise { + const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const [ + { data: announcements, error: announcementError }, + { data: membership, error: memberError }, + ] = await Promise.all([ + supabase + .from('announcements') + .select('id, workspace_id, author_id, title, content, is_pinned, created_at') + .eq('workspace_id', parsedWorkspaceId) + .order('is_pinned', { ascending: false }) + .order('created_at', { ascending: false }), + supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', parsedWorkspaceId) + .eq('user_id', currentUserId) + .maybeSingle(), + ]); + + if (announcementError) { + throw new Error(`공지 조회에 실패했습니다: ${announcementError.message}`); + } + + if (memberError) { + throw new Error(`현재 멤버 조회에 실패했습니다: ${memberError.message}`); + } + + const authorIds = [ + ...new Set( + (announcements ?? []).flatMap((notice) => (notice.author_id ? [notice.author_id] : [])), + ), + ]; + const { data: profiles, error: profileError } = authorIds.length + ? await supabase.from('profiles').select('id, real_name').in('id', authorIds) + : { data: [], error: null }; + + if (profileError) { + throw new Error(`공지 작성자 조회에 실패했습니다: ${profileError.message}`); + } + + const profileNameById = new Map( + (profiles ?? []).map((profile) => [profile.id, profile.real_name]), + ); + const notices: Notice[] = (announcements ?? []).map((notice) => ({ + id: notice.id, + workspaceId: notice.workspace_id, + authorId: notice.author_id, + title: notice.title, + content: notice.content, + authorName: notice.author_id + ? (profileNameById.get(notice.author_id) ?? '알 수 없음') + : '탈퇴한 사용자', + createdAt: toCreatedAtLabel(notice.created_at), + isPinned: notice.is_pinned, + })); + + return { + notices, + viewer: membership + ? { + userId: membership.user_id, + role: membership.role, + } + : null, + }; +} diff --git a/src/entities/notice/api/notice-actions.ts b/src/entities/notice/api/notice-actions.ts new file mode 100644 index 0000000..90045dd --- /dev/null +++ b/src/entities/notice/api/notice-actions.ts @@ -0,0 +1,164 @@ +'use server'; + +// 공지 작성·수정·삭제·고정 변경을 입력 검증과 워크스페이스 권한 확인 후 처리합니다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const uuidSchema = z.guid(); +const noticeContentSchema = z.object({ + title: z.string().trim().min(1, '공지 제목을 입력해주세요.').max(120), + content: z.string().trim().min(1, '공지 내용을 입력해주세요.').max(10_000), +}); + +type WorkspaceMember = { user_id: string; role: 'owner' | 'member' }; + +function revalidateNoticePages(workspaceId: string): void { + revalidatePath(`/workspaces/${workspaceId}/notices`); + revalidatePath(`/workspaces/${workspaceId}/dashboard`); +} + +async function getCurrentWorkspaceMember(workspaceId: string): Promise<{ + supabase: Awaited>; + member: WorkspaceMember; +}> { + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + const { data, error } = await supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', workspaceId) + .eq('user_id', currentUserId) + .maybeSingle(); + + if (error) { + throw new Error(`워크스페이스 멤버 확인에 실패했습니다: ${error.message}`); + } + + if (!data) { + throw new Error('워크스페이스 멤버만 공지를 관리할 수 있습니다.'); + } + + return { supabase, member: data }; +} + +async function getEditableAnnouncement(input: { workspaceId: string; noticeId: string }) { + const { supabase, member } = await getCurrentWorkspaceMember(input.workspaceId); + const { data: notice, error } = await supabase + .from('announcements') + .select('id, author_id') + .eq('id', input.noticeId) + .eq('workspace_id', input.workspaceId) + .maybeSingle(); + + if (error) { + throw new Error(`공지 조회에 실패했습니다: ${error.message}`); + } + + if (!notice) { + throw new Error('공지를 찾을 수 없습니다.'); + } + + if (member.role !== 'owner' && notice.author_id !== member.user_id) { + throw new Error('작성자 또는 워크스페이스 소유자만 공지를 수정하거나 삭제할 수 있습니다.'); + } + + return { supabase, member, notice }; +} + +export async function createNotice(input: { + workspaceId: string; + title: string; + content: string; +}): Promise<{ id: string }> { + const value = z.object({ workspaceId: uuidSchema }).merge(noticeContentSchema).parse(input); + const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); + const { data, error } = await supabase + .from('announcements') + .insert({ + workspace_id: value.workspaceId, + author_id: member.user_id, + title: value.title, + content: value.content, + }) + .select('id') + .single(); + + if (error) { + throw new Error(`공지 등록에 실패했습니다: ${error.message}`); + } + + revalidateNoticePages(value.workspaceId); + return { id: data.id }; +} + +export async function updateNotice(input: { + workspaceId: string; + noticeId: string; + title: string; + content: string; +}): Promise { + const value = z + .object({ workspaceId: uuidSchema, noticeId: uuidSchema }) + .merge(noticeContentSchema) + .parse(input); + const { supabase } = await getEditableAnnouncement(value); + const { error } = await supabase + .from('announcements') + .update({ title: value.title, content: value.content }) + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + throw new Error(`공지 수정에 실패했습니다: ${error.message}`); + } + + revalidateNoticePages(value.workspaceId); +} + +export async function deleteNotice(input: { + workspaceId: string; + noticeId: string; +}): Promise { + const value = z.object({ workspaceId: uuidSchema, noticeId: uuidSchema }).parse(input); + const { supabase } = await getEditableAnnouncement(value); + const { error } = await supabase + .from('announcements') + .delete() + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + throw new Error(`공지 삭제에 실패했습니다: ${error.message}`); + } + + revalidateNoticePages(value.workspaceId); +} + +export async function setNoticePinned(input: { + workspaceId: string; + noticeId: string; + isPinned: boolean; +}): Promise { + const value = z + .object({ workspaceId: uuidSchema, noticeId: uuidSchema, isPinned: z.boolean() }) + .parse(input); + const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); + + if (member.role !== 'owner') { + throw new Error('워크스페이스 소유자만 공지를 고정할 수 있습니다.'); + } + + const { error } = await supabase + .from('announcements') + .update({ is_pinned: value.isPinned }) + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + throw new Error(`공지 고정 상태 변경에 실패했습니다: ${error.message}`); + } + + revalidateNoticePages(value.workspaceId); +} diff --git a/src/entities/notice/index.ts b/src/entities/notice/index.ts index 45dbc7d..2f8aab9 100644 --- a/src/entities/notice/index.ts +++ b/src/entities/notice/index.ts @@ -1,3 +1,2 @@ -// 공지 도메인의 타입과 목업 데이터 공개 API입니다. -export type { Notice, NoticeFormValues } from './model/notice.types'; -export { mockNotices } from './model/mock-notices'; +// 공지 도메인이 외부 레이어에 제공하는 타입 공개 API입니다. +export type { Notice, NoticeBoardData, NoticeFormValues, NoticeViewer } from './model/notice.types'; diff --git a/src/entities/notice/model/mock-notices.ts b/src/entities/notice/model/mock-notices.ts deleted file mode 100644 index 94c5466..0000000 --- a/src/entities/notice/model/mock-notices.ts +++ /dev/null @@ -1,44 +0,0 @@ -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-query.ts b/src/entities/notice/model/notice-query.ts new file mode 100644 index 0000000..8cd54b3 --- /dev/null +++ b/src/entities/notice/model/notice-query.ts @@ -0,0 +1,2 @@ +// 공지 화면과 대시보드 위젯이 동일한 서버 데이터를 공유하기 위한 TanStack Query 키입니다. +export const noticeBoardQueryKey = (workspaceId: string) => ['notice-board', workspaceId] as const; diff --git a/src/entities/notice/model/notice.types.ts b/src/entities/notice/model/notice.types.ts index 8fd25b8..8847355 100644 --- a/src/entities/notice/model/notice.types.ts +++ b/src/entities/notice/model/notice.types.ts @@ -1,7 +1,8 @@ -// Supabase 공지 테이블 연결 전까지 화면 상태와 목업 데이터에서 공유하는 공지 형태입니다. +// 공지 화면과 대시보드 위젯이 공통으로 사용하는 공지 조회 형태입니다. export interface Notice { id: string; workspaceId: string; + authorId: string | null; title: string; content: string; authorName: string; @@ -13,3 +14,14 @@ export interface NoticeFormValues { title: string; content: string; } + +// 현재 사용자의 공지 권한을 UI에서 판단하기 위해 서버가 함께 내려주는 최소 멤버 정보입니다. +export interface NoticeViewer { + userId: string; + role: 'owner' | 'member'; +} + +export interface NoticeBoardData { + notices: Notice[]; + viewer: NoticeViewer | null; +} diff --git a/src/features/manage-notices/model/use-notice-board-state.ts b/src/features/manage-notices/model/use-notice-board-state.ts index deb3d0c..c11c1a3 100644 --- a/src/features/manage-notices/model/use-notice-board-state.ts +++ b/src/features/manage-notices/model/use-notice-board-state.ts @@ -1,51 +1,49 @@ 'use client'; -// 워크스페이스 공지사항 게시판의 목록 정렬, 선택, 작성/수정, 삭제, 고정 상태를 관리합니다. +// 공지 화면의 선택·작성 패널 상태와 서버 저장 후 Query 캐시 갱신을 관리합니다. import { useState } from 'react'; -import type { Notice, NoticeFormValues } from '@/entities/notice'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { + createNotice, + deleteNotice, + setNoticePinned, + updateNotice, +} from '@/entities/notice/api/notice-actions'; +import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; +import { noticeBoardQueryKey } from '@/entities/notice/model/notice-query'; +import type { NoticeBoardData, NoticeFormValues } from '@/entities/notice'; interface UseNoticeBoardStateParams { - initialNotices: Notice[]; + initialData: NoticeBoardData; 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); +export function useNoticeBoardState({ initialData, workspaceId }: UseNoticeBoardStateParams) { + const queryClient = useQueryClient(); + const { data = initialData, isPending } = useQuery({ + queryKey: noticeBoardQueryKey(workspaceId), + queryFn: () => getNoticeBoard(workspaceId), + initialData, }); -} - -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)}`; -} - -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 [selectedNoticeId, setSelectedNoticeId] = useState( + () => initialData.notices[0]?.id ?? null, ); const [editingNoticeId, setEditingNoticeId] = useState(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 selectedNotice = + data.notices.find((notice) => notice.id === selectedNoticeId) ?? data.notices[0] ?? null; + const editingNotice = data.notices.find((notice) => notice.id === editingNoticeId) ?? null; + + const refreshNoticeBoard = async () => { + await queryClient.invalidateQueries({ queryKey: noticeBoardQueryKey(workspaceId) }); + }; + + const createMutation = useMutation({ mutationFn: createNotice }); + const updateMutation = useMutation({ mutationFn: updateNotice }); + const deleteMutation = useMutation({ mutationFn: deleteNotice }); + const pinMutation = useMutation({ mutationFn: setNoticePinned }); const openCreateComposer = () => { setEditingNoticeId(null); @@ -63,7 +61,7 @@ export function useNoticeBoardState({ setIsComposerOpen(false); }; - const submitNotice = (values: NoticeFormValues) => { + const submitNotice = async (values: NoticeFormValues) => { const trimmedTitle = values.title.trim(); const trimmedContent = values.content.trim(); @@ -71,61 +69,65 @@ export function useNoticeBoardState({ return; } - if (editingNoticeId) { - setNotices((currentNotices) => - currentNotices.map((notice) => - notice.id === editingNoticeId - ? { ...notice, title: trimmedTitle, content: trimmedContent } - : notice, - ), - ); - setSelectedNoticeId(editingNoticeId); + try { + if (editingNoticeId) { + await updateMutation.mutateAsync({ + workspaceId, + noticeId: editingNoticeId, + title: trimmedTitle, + content: trimmedContent, + }); + setSelectedNoticeId(editingNoticeId); + } else { + const createdNotice = await createMutation.mutateAsync({ + workspaceId, + title: trimmedTitle, + content: trimmedContent, + }); + setSelectedNoticeId(createdNotice.id); + } + + await refreshNoticeBoard(); closeComposer(); - return; + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 저장에 실패했습니다.'); } - - 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)); + const removeNotice = async (noticeId: string) => { + try { + await deleteMutation.mutateAsync({ workspaceId, noticeId }); + await refreshNoticeBoard(); - setNotices(nextNotices); - - if (selectedNoticeId === noticeId) { - setSelectedNoticeId(nextNotices[0]?.id ?? null); - } + if (selectedNoticeId === noticeId) { + setSelectedNoticeId(null); + } - if (editingNoticeId === noticeId) { - closeComposer(); + if (editingNoticeId === noticeId) { + closeComposer(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 삭제에 실패했습니다.'); } }; - const togglePinned = (noticeId: string) => { - setNotices((currentNotices) => - sortNotices( - currentNotices.map((notice) => - notice.id === noticeId ? { ...notice, isPinned: !notice.isPinned } : notice, - ), - ), - ); - setSelectedNoticeId(noticeId); + const togglePinned = async (noticeId: string) => { + const notice = data.notices.find((item) => item.id === noticeId); + + if (!notice) return; + + try { + await pinMutation.mutateAsync({ workspaceId, noticeId, isPinned: !notice.isPinned }); + await refreshNoticeBoard(); + setSelectedNoticeId(noticeId); + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 고정 상태 변경에 실패했습니다.'); + } }; return { - notices, + notices: data.notices, + viewer: data.viewer, selectedNotice, editingNotice, isComposerOpen, @@ -134,7 +136,13 @@ export function useNoticeBoardState({ closeComposer, selectNotice: setSelectedNoticeId, submitNotice, - deleteNotice, + deleteNotice: removeNotice, togglePinned, + isPending, + isSaving: + createMutation.isPending || + updateMutation.isPending || + deleteMutation.isPending || + pinMutation.isPending, }; } diff --git a/src/features/manage-notices/ui/NoticeComposer.tsx b/src/features/manage-notices/ui/NoticeComposer.tsx index ad19f1d..0cc0e30 100644 --- a/src/features/manage-notices/ui/NoticeComposer.tsx +++ b/src/features/manage-notices/ui/NoticeComposer.tsx @@ -5,22 +5,25 @@ import type { Notice, NoticeFormValues } from '@/entities/notice'; interface NoticeComposerProps { editingNotice: Notice | null; - onSubmit: (values: NoticeFormValues) => void; + onSubmit: (values: NoticeFormValues) => Promise; onCancel: () => void; } export function NoticeComposer({ editingNotice, onSubmit, onCancel }: NoticeComposerProps) { const [title, setTitle] = useState(editingNotice?.title ?? ''); const [content, setContent] = useState(editingNotice?.content ?? ''); + const [isSubmitting, setIsSubmitting] = useState(false); const canSubmit = title.trim().length > 0 && content.trim().length > 0; return (
{ + onSubmit={async (event) => { event.preventDefault(); - onSubmit({ title, content }); + setIsSubmitting(true); + await onSubmit({ title, content }); + setIsSubmitting(false); }} >

@@ -53,14 +56,15 @@ export function NoticeComposer({ editingNotice, onSubmit, onCancel }: NoticeComp
+ {canManage ? ( +
+ - {isMenuOpen ? ( -
- - - -
- ) : null} -
+ {isMenuOpen ? ( +
+ {canEdit ? ( + + ) : null} + {canPin ? ( + + ) : null} + {canEdit ? ( + + ) : null} +
+ ) : null} +
+ ) : null} ); })} diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index 61690b7..2efb5a9 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -46,7 +46,7 @@ export const WIDGET_CATALOG = { 'recent-notices': { layout: { i: 'recent-notices', x: 6, y: 5, w: 6, h: 5, minW: 2, minH: 3 }, title: '최근 공지', - render: (size) => , + render: (size, { workspaceId }) => , }, 'recent-resources': { layout: { i: 'recent-resources', x: 0, y: 10, w: 6, h: 5, minW: 2, minH: 3 }, diff --git a/src/views/store-operation/notices/ui/NoticesView.tsx b/src/views/store-operation/notices/ui/NoticesView.tsx index d2b12b9..8698f4c 100644 --- a/src/views/store-operation/notices/ui/NoticesView.tsx +++ b/src/views/store-operation/notices/ui/NoticesView.tsx @@ -1,8 +1,7 @@ 'use client'; import { Plus } from 'lucide-react'; -import { mockNotices } from '@/entities/notice'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import type { NoticeBoardData } from '@/entities/notice'; import { NoticeComposer, NoticeDetailPanel, @@ -12,9 +11,10 @@ import { interface NoticesViewProps { workspaceId: string; + initialData: NoticeBoardData; } -export function NoticesView({ workspaceId }: NoticesViewProps) { +export function NoticesView({ workspaceId, initialData }: NoticesViewProps) { const { notices, selectedNotice, @@ -27,10 +27,11 @@ export function NoticesView({ workspaceId }: NoticesViewProps) { submitNotice, deleteNotice, togglePinned, + viewer, + isSaving, } = useNoticeBoardState({ - initialNotices: mockNotices, + initialData, workspaceId, - authorName: mockCurrentWorkspaceMember.workspaceNickname, }); return ( @@ -65,6 +66,8 @@ export function NoticesView({ workspaceId }: NoticesViewProps) { onEditNotice={openEditComposer} onDeleteNotice={deleteNotice} onTogglePinned={togglePinned} + viewer={viewer} + isSaving={isSaving} /> diff --git a/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx index 0924b69..e411a2b 100644 --- a/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx +++ b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx @@ -3,18 +3,15 @@ // · md: 리스트(제목 + 작성자·작성일) // · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성자·작성일) import { Bell, Pin } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; -import { mockNotices, type Notice } from '@/entities/notice'; +import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; +import { noticeBoardQueryKey } from '@/entities/notice/model/notice-query'; +import type { Notice } from '@/entities/notice'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; import { cn } from '@/shared/lib/utils'; -// 고정 공지 우선 → 작성일(내림차순) 정렬. 원본 배열을 변형하지 않도록 복사 후 정렬한다. -const sortedNotices = [...mockNotices].sort((a, b) => { - if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; - return b.createdAt.localeCompare(a.createdAt); -}); - const header = ( 전체 보기} /> ); @@ -36,9 +33,54 @@ function noticeMeta(notice: Notice) { return `${notice.authorName} · ${notice.createdAt}`; } -export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { +interface RecentNoticesProps { + workspaceId: string; + size?: WidgetSize; +} + +export default function RecentNotices({ workspaceId, size = 'md' }: RecentNoticesProps) { + const { data, isError, isPending } = useQuery({ + queryKey: noticeBoardQueryKey(workspaceId), + queryFn: () => getNoticeBoard(workspaceId), + }); + + if (isError) { + return ( + + {header} +
+ 최근 공지를 불러오지 못했습니다. +
+
+ ); + } + + if (isPending || !data) { + return ( + + {header} +
+ 최근 공지를 불러오는 중입니다. +
+
+ ); + } + + const notices = data.notices; + + if (notices.length === 0) { + return ( + + {header} +
+ 등록된 공지가 없습니다. +
+
+ ); + } + if (size === 'sm') { - const latest = sortedNotices[0]; + const latest = notices[0]; return ( {header} @@ -54,9 +96,9 @@ export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { return ( {header} -

총 {sortedNotices.length}개의 공지

+

총 {notices.length}개의 공지

    - {sortedNotices.map((notice) => ( + {notices.map((notice) => (
  • @@ -76,7 +118,7 @@ export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { {header}
      - {sortedNotices.map((notice) => ( + {notices.map((notice) => (
    • diff --git a/supabase/migrations/20260713010000_secure_announcements.sql b/supabase/migrations/20260713010000_secure_announcements.sql new file mode 100644 index 0000000..b7e1afc --- /dev/null +++ b/supabase/migrations/20260713010000_secure_announcements.sql @@ -0,0 +1,80 @@ +-- 공지의 조회·작성·수정·삭제·고정 권한을 분리하고 고정 상태 변경을 소유자로 제한한다. +begin; + +-- 기존 전체 CRUD/개발용 정책은 다른 permissive 정책과 OR로 결합되므로 공지 테이블에서는 제거한다. +drop policy if exists announcements_member_all on public.announcements; +drop policy if exists dev_full_access on public.announcements; + +create policy announcements_select_member +on public.announcements +for select +to authenticated +using (private.is_workspace_member(workspace_id)); + +create policy announcements_insert_member +on public.announcements +for insert +to authenticated +with check ( + private.is_workspace_member(workspace_id) + and author_id = auth.uid() +); + +create policy announcements_update_author_or_owner +on public.announcements +for update +to authenticated +using ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +) +with check ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +); + +create policy announcements_delete_author_or_owner +on public.announcements +for delete +to authenticated +using ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +); + +-- UPDATE RLS만으로는 작성자가 is_pinned 컬럼만 바꾸는 것을 구분할 수 없어 트리거에서 막는다. +create or replace function private.prevent_non_owner_announcement_pin() +returns trigger +language plpgsql +security invoker +set search_path = '' +as $$ +begin + if new.is_pinned is distinct from old.is_pinned + and not private.is_workspace_owner(new.workspace_id) then + raise exception '워크스페이스 소유자만 공지를 고정할 수 있습니다.'; + end if; + + return new; +end; +$$; + +drop trigger if exists prevent_non_owner_announcement_pin on public.announcements; +create trigger prevent_non_owner_announcement_pin +before update on public.announcements +for each row +execute function private.prevent_non_owner_announcement_pin(); + +create index if not exists idx_announcements_workspace_pinned_created +on public.announcements (workspace_id, is_pinned desc, created_at desc); + +commit; From c1b1c5495ac15f446d50e11fc1ebdb02f80579ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Tue, 14 Jul 2026 10:07:02 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=EC=8B=A4=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/api/create-workspace.ts | 2 - .../workspace/api/get-my-workspaces.ts | 16 +++- .../workspace/api/use-my-workspaces.ts | 3 +- .../ui/CreateWorkspaceDialog.tsx | 8 +- src/shared/model/database.types.ts | 13 +++- ...eate_workspace_with_authenticated_user.sql | 78 +++++++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260714003813_create_workspace_with_authenticated_user.sql diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts index 8034b3f..ad86597 100644 --- a/src/entities/workspace/api/create-workspace.ts +++ b/src/entities/workspace/api/create-workspace.ts @@ -3,7 +3,6 @@ // 워크스페이스 생성 서버액션 — create_workspace RPC // (RPC가 workspaces + owner 멤버십 + purpose별 기본 모듈 + invite_code 생성을 한 트랜잭션으로 처리) import { createSupabaseServerClient } from '@/shared/api/supabase/server'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { createWorkspaceInputSchema, type CreateWorkspaceInput, @@ -19,7 +18,6 @@ export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id const supabase = await createSupabaseServerClient(); const { data, error } = await supabase.rpc('create_workspace', { - p_user_id: DEV_USER_ID, p_name: parsed.data.name, p_purpose: toDbPurpose(parsed.data.purpose), ...(parsed.data.description ? { p_description: parsed.data.description } : {}), diff --git a/src/entities/workspace/api/get-my-workspaces.ts b/src/entities/workspace/api/get-my-workspaces.ts index 9b7b802..c8c429b 100644 --- a/src/entities/workspace/api/get-my-workspaces.ts +++ b/src/entities/workspace/api/get-my-workspaces.ts @@ -1,12 +1,24 @@ // 내 워크스페이스 목록 조회 — get_my_workspaces RPC (집계 포함 단일 쿼리, N+1 없음) import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { toUiPurpose } from '../model/purpose.mapper'; import type { WorkspaceSummary } from '../model/workspace.types'; export async function getMyWorkspaces(): Promise { const supabase = getSupabaseBrowserClient(); - const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: DEV_USER_ID }); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + throw new Error(`로그인 사용자 조회에 실패했습니다: ${userError.message}`); + } + + if (!user) { + return []; + } + + const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: user.id }); if (error) { throw new Error(`워크스페이스 목록 조회에 실패했습니다: ${error.message}`); diff --git a/src/entities/workspace/api/use-my-workspaces.ts b/src/entities/workspace/api/use-my-workspaces.ts index 4ddba28..b8f505f 100644 --- a/src/entities/workspace/api/use-my-workspaces.ts +++ b/src/entities/workspace/api/use-my-workspaces.ts @@ -2,11 +2,10 @@ // 내 워크스페이스 목록 쿼리 훅 — GET은 tanstack-query 컨벤션 import { useQuery } from '@tanstack/react-query'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { getMyWorkspaces } from './get-my-workspaces'; // 생성/수정 후 invalidateQueries({ queryKey: ['workspaces'] })로 무효화한다 -export const myWorkspacesQueryKey = ['workspaces', 'my', DEV_USER_ID] as const; +export const myWorkspacesQueryKey = ['workspaces', 'my'] as const; export function useMyWorkspaces() { return useQuery({ diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx index 04ca715..ae1c14d 100644 --- a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -28,6 +28,12 @@ interface CreateWorkspaceDialogProps { const INPUT_CLASS = 'bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 w-full rounded-[18px] border-2 border-transparent px-4.5 text-sm transition-colors focus-visible:border-brand focus-visible:ring-0'; +const WORKSPACE_NAME_PLACEHOLDERS: Record = { + 'team-project': '예: 캡스톤 디자인 팀', + 'side-project': '예: Syncly 프로젝트 팀', + 'store-operation': '예: 카페 Syncly 운영', +}; + export default function CreateWorkspaceDialog({ purpose, open, @@ -124,7 +130,7 @@ export default function CreateWorkspaceDialog({ Date: Tue, 14 Jul 2026 10:07:11 +0900 Subject: [PATCH 3/8] =?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=20Shell=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=97=B0=EB=8F=99(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/workspaces/[workspaceId]/layout.tsx | 10 +++- src/app/workspaces/[workspaceId]/page.tsx | 2 +- .../api/get-current-workspace-member.ts | 60 +++++++++++++++++++ .../workspace-shell/ui/WorkspaceHeader.tsx | 7 ++- .../workspace-shell/ui/WorkspaceShell.tsx | 12 +++- .../workspace-shell/ui/WorkspaceSidebar.tsx | 10 ++-- 6 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/entities/workspace-member/api/get-current-workspace-member.ts diff --git a/src/app/workspaces/[workspaceId]/layout.tsx b/src/app/workspaces/[workspaceId]/layout.tsx index 01838a1..536b42b 100644 --- a/src/app/workspaces/[workspaceId]/layout.tsx +++ b/src/app/workspaces/[workspaceId]/layout.tsx @@ -1,6 +1,7 @@ // 워크스페이스 공통 사이드바와 헤더를 적용하는 라우트 레이아웃입니다. import { notFound } from 'next/navigation'; import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; +import { getCurrentWorkspaceMember } from '@/entities/workspace-member/api/get-current-workspace-member'; import { WorkspaceShell } from '@/widgets/workspace-shell'; interface WorkspaceLayoutProps { @@ -12,14 +13,17 @@ interface WorkspaceLayoutProps { export default async function WorkspaceLayout({ children, params }: WorkspaceLayoutProps) { const { workspaceId } = await params; - const workspace = await getWorkspaceById(workspaceId); + const [workspace, currentMember] = await Promise.all([ + getWorkspaceById(workspaceId), + getCurrentWorkspaceMember(workspaceId), + ]); - if (!workspace) { + if (!workspace || !currentMember) { notFound(); } return ( - + {children} ); diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index 2be2246..3a854f5 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -16,7 +16,7 @@ export default async function WorkspaceHomePage({ params }: WorkspaceHomePagePro } if (workspace.purpose === 'store-operation') { - redirect(`/workspaces/${workspaceId}/work-schedule`); + redirect(`/workspaces/${workspaceId}/dashboard`); } if (workspace.purpose === 'side-project') { redirect(`/workspaces/${workspaceId}/sprint-board`); diff --git a/src/entities/workspace-member/api/get-current-workspace-member.ts b/src/entities/workspace-member/api/get-current-workspace-member.ts new file mode 100644 index 0000000..fef811d --- /dev/null +++ b/src/entities/workspace-member/api/get-current-workspace-member.ts @@ -0,0 +1,60 @@ +// 현재 로그인한 사용자의 프로필과 워크스페이스 내 역할을 Shell 표시용으로 조회한다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { WorkspaceMember } from '../model/workspace-member.types'; + +export const getCurrentWorkspaceMember = cache( + async (workspaceId: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + throw new Error(`로그인 사용자 조회에 실패했습니다: ${userError.message}`); + } + + if (!user) { + return null; + } + + const { data: membership, error: membershipError } = await supabase + .from('workspace_members') + .select('workspace_id, user_id, workspace_nickname, role') + .eq('workspace_id', workspaceId) + .eq('user_id', user.id) + .maybeSingle(); + + if (membershipError) { + throw new Error(`현재 워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); + } + + if (!membership) { + return null; + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('email, real_name') + .eq('id', user.id) + .maybeSingle(); + + if (profileError) { + throw new Error(`현재 사용자 프로필 조회에 실패했습니다: ${profileError.message}`); + } + + const displayName = + profile?.real_name || membership.workspace_nickname || user.email || '사용자'; + + return { + workspaceId: membership.workspace_id, + userId: membership.user_id, + workspaceNickname: membership.workspace_nickname || displayName, + avatarLabel: displayName.slice(0, 1), + email: profile?.email || user.email || '', + role: membership.role, + status: 'joined', + }; + }, +); diff --git a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx index 8903d3b..dbb17b6 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx @@ -3,11 +3,12 @@ // 하나의 워크스페이스 내부 페이지에서 공통으로 사용하는 상단 헤더입니다. import { Bell, Search, UserRoundPlus } from 'lucide-react'; import { usePathname } from 'next/navigation'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; interface WorkspaceHeaderProps { navigationItems: WorkspaceNavigationItem[]; + currentMember: WorkspaceMember; } function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigationItem[]): string { @@ -16,7 +17,7 @@ function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigat return currentNavigationItem?.label ?? '대시보드'; } -export function WorkspaceHeader({ navigationItems }: WorkspaceHeaderProps) { +export function WorkspaceHeader({ navigationItems, currentMember }: WorkspaceHeaderProps) { const pathname = usePathname(); const title = getCurrentPageTitle(pathname, navigationItems); @@ -52,7 +53,7 @@ export function WorkspaceHeader({ navigationItems }: WorkspaceHeaderProps) {
      - {mockCurrentWorkspaceMember.avatarLabel} + {currentMember.avatarLabel}
      diff --git a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx index 5ae01ff..d5e4f1b 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx @@ -3,6 +3,7 @@ // 공통 워크스페이스 사이드바, 헤더, 페이지 콘텐츠 프레임을 조합합니다. import { useState } from 'react'; import type { Workspace } from '@/entities/workspace'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceSidebar } from './WorkspaceSidebar'; import { getWorkspaceNavigation } from '@/widgets/workspace-shell/lib/get-workspace-navigation'; @@ -10,10 +11,16 @@ import { getWorkspaceNavigation } from '@/widgets/workspace-shell/lib/get-worksp interface WorkspaceShellProps { workspace: Workspace; workspaceId: string; + currentMember: WorkspaceMember; children: React.ReactNode; } -export function WorkspaceShell({ workspace, workspaceId, children }: WorkspaceShellProps) { +export function WorkspaceShell({ + workspace, + workspaceId, + currentMember, + children, +}: WorkspaceShellProps) { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const navigationItems = getWorkspaceNavigation(workspace.purpose); @@ -25,10 +32,11 @@ export function WorkspaceShell({ workspace, workspaceId, children }: WorkspaceSh isCollapsed={isSidebarCollapsed} navigationItems={navigationItems} onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)} + currentMember={currentMember} />
      - +
      {children}
    diff --git a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx index 626031b..f4083b6 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx @@ -6,13 +6,14 @@ import Link from 'next/link'; import { ChevronRight, LogOut, Menu, Store } from 'lucide-react'; import { usePathname } from 'next/navigation'; import type { Workspace } from '@/entities/workspace'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import { cn } from '@/shared/lib/utils'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; interface WorkspaceSidebarProps { workspace: Workspace; workspaceId: string; + currentMember: WorkspaceMember; isCollapsed: boolean; navigationItems: WorkspaceNavigationItem[]; onToggleCollapsed: () => void; @@ -21,6 +22,7 @@ interface WorkspaceSidebarProps { export function WorkspaceSidebar({ workspace, workspaceId, + currentMember, isCollapsed, navigationItems, onToggleCollapsed, @@ -117,14 +119,14 @@ export function WorkspaceSidebar({ >
    - {mockCurrentWorkspaceMember.avatarLabel} + {currentMember.avatarLabel} - {mockCurrentWorkspaceMember.workspaceNickname} + {currentMember.workspaceNickname} - {mockCurrentWorkspaceMember.role === 'owner' ? '매니저' : '멤버'} + {currentMember.role === 'owner' ? '매니저' : '멤버'}
    From 288f052854bb3d99f64d78cbb5acef9be55c935a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Tue, 14 Jul 2026 11:08:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=EA=B7=BC=EB=AC=B4=ED=91=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EB=B0=B0=EC=A0=95=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/work-schedule-actions.ts | 5 ++++ .../model/use-work-schedule-state.ts | 26 ++++++++++++++++++- .../ui/WorkScheduleBoard.tsx | 15 +++++++++-- .../workspace-shell/ui/WorkspaceSidebar.tsx | 7 ++--- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/entities/work-schedule/api/work-schedule-actions.ts b/src/entities/work-schedule/api/work-schedule-actions.ts index 5d49d72..4724292 100644 --- a/src/entities/work-schedule/api/work-schedule-actions.ts +++ b/src/entities/work-schedule/api/work-schedule-actions.ts @@ -5,7 +5,9 @@ import { revalidatePath } from 'next/cache'; import { z } from 'zod'; import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { getCurrentWeekRange } from '../lib/work-date'; import type { WorkShiftColor, WorkShiftOption } from '../model/work-schedule.types'; +import { ensureWeeklyWorkScheduleEntries } from './ensure-weekly-work-schedule-entries'; const colorSchema = z.enum(['sky', 'violet', 'amber', 'slate', 'emerald', 'rose']); // 개발 시드 UUID처럼 RFC 버전 비트가 0인 GUID도 허용한다. @@ -134,6 +136,9 @@ export async function createWorkShiftType(workspaceId: string): Promise completeScheduleEntries(params)); const cycleCell = (userId: string, weekday: WeekdayKey): WorkScheduleEntry | null => { @@ -77,6 +77,29 @@ export function useWorkScheduleState(params: UseWorkScheduleStateParams) { return nextEntry; }; + const completeMissingEntries = (shiftTypeId: string): void => { + setSchedule((current) => { + const existingEntries = new Set(current.map((entry) => `${entry.userId}:${entry.weekday}`)); + + return [ + ...current, + ...members.flatMap((member) => + weekdays.flatMap((weekday) => { + if (existingEntries.has(`${member.userId}:${weekday.key}`)) return []; + + return { + workspaceId: member.workspaceId, + userId: member.userId, + weekday: weekday.key, + workDate: getWorkDateByWeekday(weekStartDate, weekday.key), + shiftTypeId, + }; + }), + ), + ]; + }); + }; + const replaceShiftOption = (fromShiftTypeId: string, toShiftTypeId: string): void => { setSchedule((current) => { return current.map((entry) => @@ -88,6 +111,7 @@ export function useWorkScheduleState(params: UseWorkScheduleStateParams) { return { schedule, cycleCell, + completeMissingEntries, replaceShiftOption, }; } diff --git a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx index cc2e958..4b1a02b 100644 --- a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx +++ b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx @@ -9,6 +9,7 @@ import { weekdays, type WorkScheduleConfig, type WorkScheduleEntry, + type WorkShiftOption, } from '@/entities/work-schedule'; import { createWorkShiftType, @@ -39,6 +40,14 @@ interface WorkScheduleBoardProps { weekStartDate: string; } +function canPersistShift(shift: WorkShiftOption): boolean { + if (shift.isOff) return true; + + if (!shift.startTime || !shift.endTime) return false; + + return shift.endsNextDay || shift.endTime > shift.startTime; +} + export function WorkScheduleBoard({ workspaceId, members, @@ -50,7 +59,7 @@ export function WorkScheduleBoard({ const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [shiftToDeleteId, setShiftToDeleteId] = useState(null); const [replacementShiftId, setReplacementShiftId] = useState(''); - const { schedule, cycleCell, replaceShiftOption } = useWorkScheduleState({ + const { schedule, cycleCell, completeMissingEntries, replaceShiftOption } = useWorkScheduleState({ initialSchedule, members, config: scheduleConfig, @@ -61,6 +70,7 @@ export function WorkScheduleBoard({ try { const newShift = await createWorkShiftType(workspaceId); setScheduleConfig((current) => ({ shifts: [...current.shifts, newShift] })); + completeMissingEntries(newShift.id); } catch (error) { console.error(error); toast.error('근무 유형을 추가하지 못했습니다.'); @@ -78,7 +88,8 @@ export function WorkScheduleBoard({ const handleCommitShift = async (shiftId: string): Promise => { const shift = scheduleConfig.shifts.find((item) => item.id === shiftId); - if (!shift) return; + // 시작·종료 시간을 순서대로 고치는 동안의 임시 시간값은 저장하지 않는다. + if (!shift || !canPersistShift(shift)) return; try { await updateWorkShiftType({ workspaceId, ...shift }); diff --git a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx index f4083b6..59963f7 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx @@ -64,8 +64,9 @@ export function WorkspaceSidebar({
    - +